@turmind/halo 2.2.0 → 2.2.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.
@@ -1 +1 @@
1
- <!DOCTYPE html><!--Rs9tIYSSITPLxv274DU9f--><html lang="en" class="dark"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="preload" href="/_next/static/media/e4af272ccee01ff0-s.p.woff2" as="font" crossorigin="" type="font/woff2"/><link rel="stylesheet" href="/_next/static/css/7b736202f0b1dbfd.css" data-precedence="next"/><link rel="stylesheet" href="/_next/static/css/8fc43fce83e5ce5d.css" data-precedence="next"/><link rel="stylesheet" href="/_next/static/css/659eccb5db697b76.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-9532ec7f1fa9a757.js"/><script src="/_next/static/chunks/1b86ba38-4193816aebc732b9.js" async=""></script><script src="/_next/static/chunks/9698-0157e7ac70c3f9fc.js" async=""></script><script src="/_next/static/chunks/main-app-5e981adb363c52e5.js" async=""></script><script src="/_next/static/chunks/1989-dab5d5c24b8f4c9f.js" async=""></script><script src="/_next/static/chunks/app/layout-0ed29966e0abc26b.js" async=""></script><script src="/_next/static/chunks/1d2d5650-c677408087984c7a.js" async=""></script><script src="/_next/static/chunks/1607-bf227fc5e4db45b7.js" async=""></script><script src="/_next/static/chunks/app/page-cca81a2ed69100f5.js" async=""></script><meta name="next-size-adjust" content=""/><title>Halo - Multi-Agent Workspace</title><meta name="description" content="Multi-agent collaboration workspace for complex project delivery"/><link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16"/><link rel="icon" href="/icon.svg?3a6863548daca964" type="image/svg+xml" sizes="any"/><script>try{var t=localStorage.getItem('halo_theme');if(t==='light'||t==='midnight'||t==='warm')document.documentElement.dataset.theme=t}catch(e){}</script><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body class="__variable_d8ebd1 font-sans antialiased"><div hidden=""><!--$--><!--/$--></div><div class="flex h-screen w-screen items-center justify-center bg-[var(--background)]"><div class="text-sm text-[var(--muted-foreground)]">Loading...</div></div><!--$--><!--/$--><script src="/_next/static/chunks/webpack-9532ec7f1fa9a757.js" id="_R_" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0])</script><script>self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[58321,[\"1989\",\"static/chunks/1989-dab5d5c24b8f4c9f.js\",\"7177\",\"static/chunks/app/layout-0ed29966e0abc26b.js\"],\"ThemeProvider\"]\n3:I[24554,[\"1989\",\"static/chunks/1989-dab5d5c24b8f4c9f.js\",\"7177\",\"static/chunks/app/layout-0ed29966e0abc26b.js\"],\"AppI18nProvider\"]\n4:I[63693,[],\"\"]\n5:I[74713,[],\"\"]\n6:I[5444,[],\"ClientPageRoot\"]\n7:I[12443,[\"4560\",\"static/chunks/1d2d5650-c677408087984c7a.js\",\"1607\",\"static/chunks/1607-bf227fc5e4db45b7.js\",\"1989\",\"static/chunks/1989-dab5d5c24b8f4c9f.js\",\"8974\",\"static/chunks/app/page-cca81a2ed69100f5.js\"],\"default\"]\na:I[23632,[],\"OutletBoundary\"]\nc:I[86889,[],\"AsyncMetadataOutlet\"]\ne:I[23632,[],\"ViewportBoundary\"]\n10:I[23632,[],\"MetadataBoundary\"]\n11:\"$Sreact.suspense\"\n13:I[1935,[],\"\"]\n:HL[\"/_next/static/media/e4af272ccee01ff0-s.p.woff2\",\"font\",{\"crossOrigin\":\"\",\"type\":\"font/woff2\"}]\n:HL[\"/_next/static/css/7b736202f0b1dbfd.css\",\"style\"]\n:HL[\"/_next/static/css/8fc43fce83e5ce5d.css\",\"style\"]\n:HL[\"/_next/static/css/659eccb5db697b76.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"0:{\"P\":null,\"b\":\"Rs9tIYSSITPLxv274DU9f\",\"p\":\"\",\"c\":[\"\",\"\"],\"i\":false,\"f\":[[[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],[\"\",[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/7b736202f0b1dbfd.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}],[\"$\",\"link\",\"1\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/8fc43fce83e5ce5d.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"className\":\"dark\",\"suppressHydrationWarning\":true,\"children\":[[\"$\",\"head\",null,{\"children\":[\"$\",\"script\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"try{var t=localStorage.getItem('halo_theme');if(t==='light'||t==='midnight'||t==='warm')document.documentElement.dataset.theme=t}catch(e){}\"}}]}],[\"$\",\"body\",null,{\"className\":\"__variable_d8ebd1 font-sans antialiased\",\"children\":[\"$\",\"$L2\",null,{\"children\":[\"$\",\"$L3\",null,{\"children\":[\"$\",\"$L4\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L5\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],[]],\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]}]}]}]]}]]}],{\"children\":[\"__PAGE__\",[\"$\",\"$1\",\"c\",{\"children\":[[\"$\",\"$L6\",null,{\"Component\":\"$7\",\"searchParams\":{},\"params\":{},\"promises\":[\"$@8\",\"$@9\"]}],[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/659eccb5db697b76.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"$La\",null,{\"children\":[\"$Lb\",[\"$\",\"$Lc\",null,{\"promise\":\"$@d\"}]]}]]}],{},null,false]},null,false],[\"$\",\"$1\",\"h\",{\"children\":[null,[[\"$\",\"$Le\",null,{\"children\":\"$Lf\"}],[\"$\",\"meta\",null,{\"name\":\"next-size-adjust\",\"content\":\"\"}]],[\"$\",\"$L10\",null,{\"children\":[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$11\",null,{\"fallback\":null,\"children\":\"$L12\"}]}]}]]}],false]],\"m\":\"$undefined\",\"G\":[\"$13\",[]],\"s\":false,\"S\":true}\n"])</script><script>self.__next_f.push([1,"8:{}\n9:\"$0:f:0:1:2:children:1:props:children:0:props:params\"\n"])</script><script>self.__next_f.push([1,"f:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"1\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]\nb:null\n"])</script><script>self.__next_f.push([1,"14:I[88809,[],\"IconMark\"]\nd:{\"metadata\":[[\"$\",\"title\",\"0\",{\"children\":\"Halo - Multi-Agent Workspace\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"Multi-agent collaboration workspace for complex project delivery\"}],[\"$\",\"link\",\"2\",{\"rel\":\"icon\",\"href\":\"/favicon.ico\",\"type\":\"image/x-icon\",\"sizes\":\"16x16\"}],[\"$\",\"link\",\"3\",{\"rel\":\"icon\",\"href\":\"/icon.svg?3a6863548daca964\",\"type\":\"image/svg+xml\",\"sizes\":\"any\"}],[\"$\",\"$L14\",\"4\",{}]],\"error\":null,\"digest\":\"$undefined\"}\n"])</script><script>self.__next_f.push([1,"12:\"$d:metadata\"\n"])</script></body></html>
1
+ <!DOCTYPE html><!--u9pg60rITCj73MBYeXsW1--><html lang="en" class="dark"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="preload" href="/_next/static/media/e4af272ccee01ff0-s.p.woff2" as="font" crossorigin="" type="font/woff2"/><link rel="stylesheet" href="/_next/static/css/7b736202f0b1dbfd.css" data-precedence="next"/><link rel="stylesheet" href="/_next/static/css/8fc43fce83e5ce5d.css" data-precedence="next"/><link rel="stylesheet" href="/_next/static/css/659eccb5db697b76.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-9532ec7f1fa9a757.js"/><script src="/_next/static/chunks/1b86ba38-4193816aebc732b9.js" async=""></script><script src="/_next/static/chunks/9698-0157e7ac70c3f9fc.js" async=""></script><script src="/_next/static/chunks/main-app-5e981adb363c52e5.js" async=""></script><script src="/_next/static/chunks/1989-3b2faa2731d973c4.js" async=""></script><script src="/_next/static/chunks/app/layout-0ed29966e0abc26b.js" async=""></script><script src="/_next/static/chunks/1d2d5650-c677408087984c7a.js" async=""></script><script src="/_next/static/chunks/1607-bf227fc5e4db45b7.js" async=""></script><script src="/_next/static/chunks/app/page-10a4afac9cbc27a2.js" async=""></script><meta name="next-size-adjust" content=""/><title>Halo - Multi-Agent Workspace</title><meta name="description" content="Multi-agent collaboration workspace for complex project delivery"/><link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16"/><link rel="icon" href="/icon.svg?3a6863548daca964" type="image/svg+xml" sizes="any"/><script>try{var t=localStorage.getItem('halo_theme');if(t==='light'||t==='midnight'||t==='warm')document.documentElement.dataset.theme=t}catch(e){}</script><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body class="__variable_d8ebd1 font-sans antialiased"><div hidden=""><!--$--><!--/$--></div><div class="flex h-screen w-screen items-center justify-center bg-[var(--background)]"><div class="text-sm text-[var(--muted-foreground)]">Loading...</div></div><!--$--><!--/$--><script src="/_next/static/chunks/webpack-9532ec7f1fa9a757.js" id="_R_" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0])</script><script>self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[58321,[\"1989\",\"static/chunks/1989-3b2faa2731d973c4.js\",\"7177\",\"static/chunks/app/layout-0ed29966e0abc26b.js\"],\"ThemeProvider\"]\n3:I[24554,[\"1989\",\"static/chunks/1989-3b2faa2731d973c4.js\",\"7177\",\"static/chunks/app/layout-0ed29966e0abc26b.js\"],\"AppI18nProvider\"]\n4:I[63693,[],\"\"]\n5:I[74713,[],\"\"]\n6:I[5444,[],\"ClientPageRoot\"]\n7:I[47333,[\"4560\",\"static/chunks/1d2d5650-c677408087984c7a.js\",\"1607\",\"static/chunks/1607-bf227fc5e4db45b7.js\",\"1989\",\"static/chunks/1989-3b2faa2731d973c4.js\",\"8974\",\"static/chunks/app/page-10a4afac9cbc27a2.js\"],\"default\"]\na:I[23632,[],\"OutletBoundary\"]\nc:I[86889,[],\"AsyncMetadataOutlet\"]\ne:I[23632,[],\"ViewportBoundary\"]\n10:I[23632,[],\"MetadataBoundary\"]\n11:\"$Sreact.suspense\"\n13:I[1935,[],\"\"]\n:HL[\"/_next/static/media/e4af272ccee01ff0-s.p.woff2\",\"font\",{\"crossOrigin\":\"\",\"type\":\"font/woff2\"}]\n:HL[\"/_next/static/css/7b736202f0b1dbfd.css\",\"style\"]\n:HL[\"/_next/static/css/8fc43fce83e5ce5d.css\",\"style\"]\n:HL[\"/_next/static/css/659eccb5db697b76.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"0:{\"P\":null,\"b\":\"u9pg60rITCj73MBYeXsW1\",\"p\":\"\",\"c\":[\"\",\"\"],\"i\":false,\"f\":[[[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],[\"\",[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/7b736202f0b1dbfd.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}],[\"$\",\"link\",\"1\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/8fc43fce83e5ce5d.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"className\":\"dark\",\"suppressHydrationWarning\":true,\"children\":[[\"$\",\"head\",null,{\"children\":[\"$\",\"script\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"try{var t=localStorage.getItem('halo_theme');if(t==='light'||t==='midnight'||t==='warm')document.documentElement.dataset.theme=t}catch(e){}\"}}]}],[\"$\",\"body\",null,{\"className\":\"__variable_d8ebd1 font-sans antialiased\",\"children\":[\"$\",\"$L2\",null,{\"children\":[\"$\",\"$L3\",null,{\"children\":[\"$\",\"$L4\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L5\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],[]],\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]}]}]}]]}]]}],{\"children\":[\"__PAGE__\",[\"$\",\"$1\",\"c\",{\"children\":[[\"$\",\"$L6\",null,{\"Component\":\"$7\",\"searchParams\":{},\"params\":{},\"promises\":[\"$@8\",\"$@9\"]}],[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/659eccb5db697b76.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"$La\",null,{\"children\":[\"$Lb\",[\"$\",\"$Lc\",null,{\"promise\":\"$@d\"}]]}]]}],{},null,false]},null,false],[\"$\",\"$1\",\"h\",{\"children\":[null,[[\"$\",\"$Le\",null,{\"children\":\"$Lf\"}],[\"$\",\"meta\",null,{\"name\":\"next-size-adjust\",\"content\":\"\"}]],[\"$\",\"$L10\",null,{\"children\":[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$11\",null,{\"fallback\":null,\"children\":\"$L12\"}]}]}]]}],false]],\"m\":\"$undefined\",\"G\":[\"$13\",[]],\"s\":false,\"S\":true}\n"])</script><script>self.__next_f.push([1,"8:{}\n9:\"$0:f:0:1:2:children:1:props:children:0:props:params\"\n"])</script><script>self.__next_f.push([1,"f:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"1\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]\nb:null\n"])</script><script>self.__next_f.push([1,"14:I[88809,[],\"IconMark\"]\nd:{\"metadata\":[[\"$\",\"title\",\"0\",{\"children\":\"Halo - Multi-Agent Workspace\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"Multi-agent collaboration workspace for complex project delivery\"}],[\"$\",\"link\",\"2\",{\"rel\":\"icon\",\"href\":\"/favicon.ico\",\"type\":\"image/x-icon\",\"sizes\":\"16x16\"}],[\"$\",\"link\",\"3\",{\"rel\":\"icon\",\"href\":\"/icon.svg?3a6863548daca964\",\"type\":\"image/svg+xml\",\"sizes\":\"any\"}],[\"$\",\"$L14\",\"4\",{}]],\"error\":null,\"digest\":\"$undefined\"}\n"])</script><script>self.__next_f.push([1,"12:\"$d:metadata\"\n"])</script></body></html>
@@ -1,10 +1,10 @@
1
1
  1:"$Sreact.fragment"
2
- 2:I[58321,["1989","static/chunks/1989-dab5d5c24b8f4c9f.js","7177","static/chunks/app/layout-0ed29966e0abc26b.js"],"ThemeProvider"]
3
- 3:I[24554,["1989","static/chunks/1989-dab5d5c24b8f4c9f.js","7177","static/chunks/app/layout-0ed29966e0abc26b.js"],"AppI18nProvider"]
2
+ 2:I[58321,["1989","static/chunks/1989-3b2faa2731d973c4.js","7177","static/chunks/app/layout-0ed29966e0abc26b.js"],"ThemeProvider"]
3
+ 3:I[24554,["1989","static/chunks/1989-3b2faa2731d973c4.js","7177","static/chunks/app/layout-0ed29966e0abc26b.js"],"AppI18nProvider"]
4
4
  4:I[63693,[],""]
5
5
  5:I[74713,[],""]
6
6
  6:I[5444,[],"ClientPageRoot"]
7
- 7:I[12443,["4560","static/chunks/1d2d5650-c677408087984c7a.js","1607","static/chunks/1607-bf227fc5e4db45b7.js","1989","static/chunks/1989-dab5d5c24b8f4c9f.js","8974","static/chunks/app/page-cca81a2ed69100f5.js"],"default"]
7
+ 7:I[47333,["4560","static/chunks/1d2d5650-c677408087984c7a.js","1607","static/chunks/1607-bf227fc5e4db45b7.js","1989","static/chunks/1989-3b2faa2731d973c4.js","8974","static/chunks/app/page-10a4afac9cbc27a2.js"],"default"]
8
8
  a:I[23632,[],"OutletBoundary"]
9
9
  c:I[86889,[],"AsyncMetadataOutlet"]
10
10
  e:I[23632,[],"ViewportBoundary"]
@@ -15,7 +15,7 @@ e:I[23632,[],"ViewportBoundary"]
15
15
  :HL["/_next/static/css/7b736202f0b1dbfd.css","style"]
16
16
  :HL["/_next/static/css/8fc43fce83e5ce5d.css","style"]
17
17
  :HL["/_next/static/css/659eccb5db697b76.css","style"]
18
- 0:{"P":null,"b":"Rs9tIYSSITPLxv274DU9f","p":"","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/7b736202f0b1dbfd.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/_next/static/css/8fc43fce83e5ce5d.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"dark","suppressHydrationWarning":true,"children":[["$","head",null,{"children":["$","script",null,{"dangerouslySetInnerHTML":{"__html":"try{var t=localStorage.getItem('halo_theme');if(t==='light'||t==='midnight'||t==='warm')document.documentElement.dataset.theme=t}catch(e){}"}}]}],["$","body",null,{"className":"__variable_d8ebd1 font-sans antialiased","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]]}]]}],{"children":["__PAGE__",["$","$1","c",{"children":[["$","$L6",null,{"Component":"$7","searchParams":{},"params":{},"promises":["$@8","$@9"]}],[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/659eccb5db697b76.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","$La",null,{"children":["$Lb",["$","$Lc",null,{"promise":"$@d"}]]}]]}],{},null,false]},null,false],["$","$1","h",{"children":[null,[["$","$Le",null,{"children":"$Lf"}],["$","meta",null,{"name":"next-size-adjust","content":""}]],["$","$L10",null,{"children":["$","div",null,{"hidden":true,"children":["$","$11",null,{"fallback":null,"children":"$L12"}]}]}]]}],false]],"m":"$undefined","G":["$13",[]],"s":false,"S":true}
18
+ 0:{"P":null,"b":"u9pg60rITCj73MBYeXsW1","p":"","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/7b736202f0b1dbfd.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/_next/static/css/8fc43fce83e5ce5d.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"dark","suppressHydrationWarning":true,"children":[["$","head",null,{"children":["$","script",null,{"dangerouslySetInnerHTML":{"__html":"try{var t=localStorage.getItem('halo_theme');if(t==='light'||t==='midnight'||t==='warm')document.documentElement.dataset.theme=t}catch(e){}"}}]}],["$","body",null,{"className":"__variable_d8ebd1 font-sans antialiased","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]]}]]}],{"children":["__PAGE__",["$","$1","c",{"children":[["$","$L6",null,{"Component":"$7","searchParams":{},"params":{},"promises":["$@8","$@9"]}],[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/659eccb5db697b76.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","$La",null,{"children":["$Lb",["$","$Lc",null,{"promise":"$@d"}]]}]]}],{},null,false]},null,false],["$","$1","h",{"children":[null,[["$","$Le",null,{"children":"$Lf"}],["$","meta",null,{"name":"next-size-adjust","content":""}]],["$","$L10",null,{"children":["$","div",null,{"hidden":true,"children":["$","$11",null,{"fallback":null,"children":"$L12"}]}]}]]}],false]],"m":"$undefined","G":["$13",[]],"s":false,"S":true}
19
19
  8:{}
20
20
  9:"$0:f:0:1:2:children:1:props:children:0:props:params"
21
21
  f:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
@@ -95,7 +95,7 @@ subscribe handler
95
95
  │ (other subscriptions untouched)
96
96
  ├─ Load UIState from SessionManager (or from file if not in memory)
97
97
  │
98
- └─ Send state:snapshot carrying the full messageLog
98
+ └─ Send state:snapshot carrying the messageLog (minus tool_call / tool_result rows)
99
99
  ```
100
100
 
101
101
  ## When disconnect happens while background is still running
@@ -308,7 +308,7 @@ The hub repo keeps one directory per extension (`glb` / `ipynb` commit their ven
308
308
  - `ipynb` 2.0.1 — `.ipynb`, `default`, `save`: notebook editor (markdown, KaTeX math, highlighted code, outputs incl. images / HTML tables; no widgets / JS outputs) that runs cells through the Jupyter proxy above when the host sends `init.jupyter` (Halo ≥ 1.6.1); older hosts edit and save without run controls.
309
309
  - `drawio` 1.1.0 — `.drawio .dio`, `default`, `['save']`; offline draw.io v32.0.2 (~22 MB zip).
310
310
  - `excalidraw` 1.1.0 — `.excalidraw`, `default`, `['save']`; offline Excalidraw 0.18 (~16 MB zip).
311
- - `htrans` 1.0.9 — `.htrans`, `bundle`, `['media', 'transcribe']`, platforms `web` / `desktop-mac` / `desktop-win`: meeting recorder (mic + system sound, live captions through the [transcription proxy](#streaming-transcription-proxy), optionally labelled by speaker — the "Identify speakers" checkbox, off by default, stored in `meeting.json` `asr.speakers` — timed screenshots). One Start / Pause / Resume button sits in the header bar next to the timer; pausing ends the current run and Resume starts the next run in the same meeting. A failed package write is kept — logged in the browser's IndexedDB before it is sent — and retried in order until it lands, with a red notice ("login expired" when the `fs-result` carries `auth`); what a reload or crash left unsaved is written the next time the package opens in the same browser.
311
+ - `htrans` 1.0.10 — `.htrans`, `bundle`, `['media', 'transcribe', 'stt-local']`, platforms `web` / `desktop-mac` / `desktop-win`: meeting recorder (mic + system sound, live captions, optionally labelled by speaker — the "Identify speakers" checkbox, off by default, stored in `meeting.json` `asr.speakers` — timed screenshots). A transcription-engine picker chooses Amazon Transcribe through the [transcription proxy](#streaming-transcription-proxy) (default) or on-device SenseVoice through the [stt-local](stt-local.md) WS; "Identify speakers" works with both (the local engine gets `speakers=1`). Because it declares `stt-local`, installing it starts the shared STT model download. One Start / Pause / Resume button sits in the header bar next to the timer; pausing ends the current run and Resume starts the next run in the same meeting. A failed package write is kept — logged in the browser's IndexedDB before it is sent — and retried in order until it lands, with a red notice ("login expired" when the `fs-result` carries `auth`); what a reload or crash left unsaved is written the next time the package opens in the same browser.
312
312
  - `megadrive` 2.1.0 — `.mega`, `bundle`, `['fs-read']`: offline Mega Drive / Genesis (+ Master System / Game Gear) emulator (EmulatorJS + Genesis Plus GX); the ROM is picked anywhere on the machine and read in place. No ROMs included.
313
313
  - `arcade` 2.2.0 — `.arcade`, `bundle`, `['fs-read']`: offline arcade emulator (EmulatorJS + FinalBurn Neo), romset `.zip` read in place the same way. No ROMs included.
314
314
  - `halo-city` 1.0.2 — `extensions: []` + `activity`; `minHalo` 1.6.5: the [Halo City](halo-city.md) runtime visualizer as an activity-bar page.
@@ -64,7 +64,7 @@ A fire runs **in-process**: the prompt goes into the job's session on the server
64
64
  4. **Create or resume** the session. Missing → `createSession(job.agentId, null, 'Cron: <label|id>', undefined, sid, folderAbs, null[, title])` — access `null` = full; the display name resolves from agent.yaml; a webhook job's session is titled `Webhook: <label|id>` up front (its first message is a payload), a scheduled one takes its title from the first message. Existing with another agent → `failed` (`session X runs agent Y, not the job's Z; …`). Existing with the same agent → `setWorkingDir(sid, folderAbs|null)` re-points its folder in place (changing a job's folder moves its session too) — except a webhook fire on a busy session, which leaves it for a later run (see [Webhook trigger](#webhook-trigger)); its **access level is kept** — the send passes no level.
65
65
  5. **Re-check, then take the pointer** — past the awaits `sessionBusy` runs again, then in the same synchronous slice `takeReplyTo(db, sid, { cron: runId })`.
66
66
  6. **Track** (`trackRun`, before the send — the turn can end before `sendUserMessage` resolves): `_runs` entry, the root event listener (notices, interrupted replies), the timeout timer.
67
- 7. **Send**: `appendUserMessage(sid, job.userPrompt)` (the UI row shows the prompt alone) and `sendUserMessage(sid, UNATTENDED_PREAMBLE + prompt, …, stamp)`. The `[Unattended cron run — nobody will answer questions …]` line is added on every fire: the model can't see the `cron-*` id, and prompts typed into the admin form carry no such notice. A send that throws → own pointer cleared, `failed`. `runJob` returns the runId here; it does not wait for the run.
67
+ 7. **Send**: `appendUserMessage(sid, job.userPrompt)` (the UI row shows the prompt alone) and `sendUserMessage(sid, UNATTENDED_PREAMBLE + prompt, …, stamp)`; a webhook fire appends each claimed event's text to both, and an empty prompt (allowed on webhook jobs only) is left out of both — the UI row is the events alone, the model text the preamble + events. A run-now of an empty-prompt job is recorded `skipped` (`nothing to run`): it carries no events. The `[Unattended cron run — nobody will answer questions …]` line is added on every fire: the model can't see the `cron-*` id, and prompts typed into the admin form carry no such notice. A send that throws → own pointer cleared, `failed`. `runJob` returns the runId here; it does not wait for the run.
68
68
 
69
69
  **Session strategy.** A stateful job runs in one stable session — its own by default, or an existing **root** session picked on the job (`cron_jobs.session_id`, `CRON_MIGRATIONS` slot 1). The job's own session id **is the job id** (generated ids are already `cron-<ts>-<rand>`; a custom id without that prefix gets `cron-<id>`) — `effectiveSessionId` in `runner.ts` is the one copy of the rule, returned by the REST list as `effectiveSessionId`. Older builds always prefixed (`cron-cron-…`); slot 3 (v4) pins every job that ever ran to that session via `session_id`. The conversation accumulates and is reviewable (live) in the Sessions tab. A **stateless** job (`cron_jobs.stateless`) gets a fresh session every fire.
70
70
 
@@ -99,7 +99,7 @@ A run not resumed hands its webhook events back to pending; at the end every job
99
99
 
100
100
  ## Webhook trigger
101
101
 
102
- The third trigger mode (`CRON_MIGRATIONS` slot 6, v7): `cron_jobs.webhook_secret` non-null = a webhook job — no schedule, no `runAt`, no timer (`scheduleJob` parks it in `_expired`; `jobFingerprint` includes the flag). Excludes a picked session (picked sessions are often owed to relay / A2A; events would wait with no wake-up point) and an active window, at every write surface.
102
+ The third trigger mode (`CRON_MIGRATIONS` slot 6, v7): `cron_jobs.webhook_secret` non-null = a webhook job — no schedule, no `runAt`, no timer (`scheduleJob` parks it in `_expired`; `jobFingerprint` includes the flag). Excludes an active window, at every write surface. A picked session works as for a timer job (stateless still excludes it, the agent lock and root-only id apply): while the session is owed to another owner (relay / A2A / an invocation / another job's run) the events wait, and that owner's `clearReplyTo` wakes them (the door's `released`, see `startOrInject` below).
103
103
 
104
104
  **Receiver** — `POST /cron-hooks/:jobId` (`routes/cron-hooks.ts`, outside `/api`, public; mounted only when not AgentCore). Body ≤ 1 MB (413). Auth: GitHub `X-Hub-Signature-256` (HMAC-SHA256 of the raw body) or `Authorization: Bearer <secret>`, both `timingSafeEqual`; no job / not a webhook job / bad auth are one `404`. A GitHub `ping` → 200 before the enabled check (so creating the hook on a disabled job passes); disabled → 409. Delivery id = `X-GitHub-Delivery` / `Idempotency-Key` / `X-Request-Id`, else `sha256:<hex of body>#<receivedAt>` — matched by prefix within the last 10 minutes (`BODY_DEDUPE_MS`), so a header-less sender's retry burst is one event and the same body later a new one; the receipt time keeps it clear of `UNIQUE(job_id, delivery_id)`. A duplicate → 200 `{duplicate:true, eventId}` (insert `onConflictDoNothing` for a race) — except a repeat of a `failed` event: flipped back to `pending` (run / session / stamp cleared) and handled like a new one, 202 `{eventId, redelivered:true}`. More than 100 pending → 429. Stored → 202 `{eventId}`, then `startOrInject(jobId)` asynchronously.
105
105
 
@@ -107,7 +107,7 @@ The third trigger mode (`CRON_MIGRATIONS` slot 6, v7): `cron_jobs.webhook_secret
107
107
 
108
108
  **`startOrInject(jobId)`** — batching:
109
109
  - A run of the job is tracked here and still owns its session → claim every pending event and inject each as its own message (`appendUserMessage` + `stampEvents` + `sendUserMessage` with the stamp): folded into the running turn or queued, so one run reports a batch. An inject that throws puts that event back to pending. A manual run (run-now) counts: events arriving during it are injected into it, though its opening message carried the prompt alone.
110
- - Otherwise (job enabled and still a webhook job) `runJob(jobId, 'webhook')`: the events claimed right after `trackRun` ride the opening message (prompt + each event's text). Unlike a timer fire, a webhook fire **does not skip on a user's turn** (`sessionBusy(…, ownerOnly)`) — it queues behind it, leaving the session's folder as is (it can't change mid-turn; the job folder applies on a later run); a session owed to another owner → `''`, no row, events stay pending until it lets go: `clearReplyTo` (every owner's release — relay, A2A, `/invocations`, a cron run) calls the door's `released(sessionId)`, which kicks each webhook job (non-stateless) whose session that is and that has pending events. The same holds when the re-check past the fire's awaits finds a new owner: the `running` row it had written is deleted (no failed row, `lastRunStatus` untouched), the events stay pending. The kick only reaches this process: an owner in another process sharing the workspace db (dev and prod, a TUI) releasing the session kicks nothing here — the events wait for the next event, a restart (`recoverRuns`) or the job's next run end. Every run end (finish / timeout / cancel) kicks the job again if events are pending. A webhook fire that fails before its turn starts (workspace / folder missing, workspace db unopenable, agent mismatch, `createSession` or the send throwing) marks every event it held or found pending `failed` — not retried (a broken workspace would loop); the sender's redelivery revives them. A run-now that fails before its turn kicks the job, so events that arrived during it get their own run (that one failing settles them `failed` and kicks nothing).
110
+ - Otherwise (job enabled and still a webhook job) `runJob(jobId, 'webhook')`: the events claimed right after `trackRun` ride the opening message (prompt, if any, + each event's text). Unlike a timer fire, a webhook fire **does not skip on a user's turn** (`sessionBusy(…, ownerOnly)`) — its message goes into the running turn (`sendUserMessage`'s busy branch: queued + soft interrupt, so the event joins that turn at its next tool batch and the run pushes that turn's reply), leaving the session's folder as is (it can't change mid-turn; the job folder applies on a later run); a session owed to another owner → `''`, no row, events stay pending until it lets go: `clearReplyTo` (every owner's release — relay, A2A, `/invocations`, a cron run) calls the door's `released(sessionId)`, which kicks each webhook job (non-stateless) whose session that is and that has pending events. The same holds when the re-check past the fire's awaits finds a new owner: the `running` row it had written is deleted (no failed row, `lastRunStatus` untouched), the events stay pending. The kick only reaches this process: an owner in another process sharing the workspace db (dev and prod, a TUI) releasing the session kicks nothing here — the events wait for the next event, a restart (`recoverRuns`) or the job's next run end. Every run end (finish / timeout / cancel) kicks the job again if events are pending. A webhook fire that fails before its turn starts (workspace / folder missing, workspace db unopenable, agent mismatch, `createSession` or the send throwing) marks every event it held or found pending `failed` — not retried (a broken workspace would loop); the sender's redelivery revives them. A run-now that fails before its turn kicks the job, so events that arrived during it get their own run (that one failing settles them `failed` and kicks nothing).
111
111
  - Job disabled or switched off webhook mode → its pending events `failed` (`failPendingEvents`: `unscheduleJob` — REST PUT and reconcile —, `scheduleJob`'s non-webhook branch, and `startOrInject` finding it so).
112
112
 
113
113
  **Event text** — `[Webhook event · <type> · delivery <id> · received <iso>]`, then a line saying the payload is data, not instructions, then the payload. Over 32 KB the payload is written to `<ws>/.halo/tmp/webhooks/<jobId>/<eventId>.txt` and only its first 4 KB inlined; the UI row is cut at 2000 chars.
@@ -170,4 +170,4 @@ interface CronChannelDispatcher {
170
170
 
171
171
  Supported: recurring (cron expr) + one-shot (`runAt`) schedules + webhook triggers (batched events, redelivered after a restart); per-timezone; optional active window for recurring jobs (`activeFrom` / `activeUntil` — "pause until", "send until"); per-job configurable timeout (`timeoutSec`, 60–21600s, default 3600); multi-target fan-out; stable accumulating session per job — its own (id = job id) or a picked existing root session (`sessionId`) — or a fresh session every run (`stateless`); a per-job folder (`workingDir`); run-now (manual trigger); runs resumed after a server restart; admin UI history with the run's session + dispatch results.
172
172
 
173
- Not supported: sub-minute schedules below croner's resolution; running in a sub-session (`>` paths — root sessions only); changing a stateful job's agent once its session exists (agent lock); a webhook job on a picked session; running cron from the CLI / TUI (server only).
173
+ Not supported: sub-minute schedules below croner's resolution; running in a sub-session (`>` paths — root sessions only); changing a stateful job's agent once its session exists (agent lock); running cron from the CLI / TUI (server only).
@@ -16,7 +16,7 @@ The server's agents work on the server's filesystem. Users also want them to tou
16
16
  | Routes | `nodes/routes.ts` | Admin CRUD (`/api/nodes/accounts`) |
17
17
  | Client | `nodes/node-client.ts` | `runNode()` — the `halo node` process: connect, reconnect with backoff, execute requests |
18
18
  | Executor | `nodes/node-exec.ts` + `node-path.ts` | Machine side. Wraps the **same** `createWorkspaceTools(base, { accessLevel: 'full' })` the server's agents use; adds the root check |
19
- | Go client | `packages/node-go/` | `halo-node`: a static Go binary (linux amd64 / arm64 / armv7, no cgo, ~7 MB) speaking the same protocol v1 with the same flags. It ports the client, the executor and the parts of `workspace-tools.ts` / `sandbox.ts` it needs (the seven ops, rm guard, process-group kill), with JS value, UTF-16 and V8 regex semantics (`internal/jsv`, `internal/jsregex` over regexp2). Ships as release assets via `.github/workflows/node-go.yml`, not npm |
19
+ | Go client | `packages/node-go/` | `halo-node`: a standalone Go binary (Linux amd64 / arm64 / armv7 and Windows amd64, no cgo) speaking the same protocol v1 with the same flags. It ports the client, the executor and the parts of `workspace-tools.ts` / `sandbox.ts` it needs (the seven ops, POSIX rm guard, process-group / Windows Job Object kill), with JS value, UTF-16 and V8 regex semantics (`internal/jsv`, `internal/jsregex` over regexp2). Ships as release assets via `.github/workflows/node-go.yml`, not npm |
20
20
  | Conformance | `packages/server/test/node-go-conformance.test.ts` + `packages/node-go/conformance/` | The TS executor is the oracle: a real hub connects TS `runNode` (`test/helpers/ts-node-main.ts`) and the Go binary side by side, and every result, file and log line is compared. Skipped unless `HALO_NODE_GO_BIN` is set. node-go.yml runs it, along with rm-guard vector `--check` and a 20k regex differential fuzz, on any PR touching either side, so a TS change goes red until Go follows. Accepted differences: [below](#go-client-known-differences-from-the-ts-client) |
21
21
 
22
22
  Not built in AgentCore mode (`nodeHub = AGENTCORE ? null : …`), the CLI / TUI or the evo sandbox — there the tools exist but return `NODES_UNAVAILABLE`.
@@ -42,6 +42,12 @@ Accepted gaps between `halo-node` and `halo node`. In the conformance suite D1 i
42
42
  | D14 | `node_run`'s stdin is a pipe (FIFO), TS's a socketpair: reads and writes behave the same; only a test like `[ -S /dev/stdin ]` tells them apart | nothing writes to it either way |
43
43
  | D15 | Built with `CGO_ENABLED=0`, user lookup reads `/etc/passwd` only, not NSS: for an LDAP / SSSD user the name in the `--allow-shell` log line and the home used when `HOME` is unset can differ (TS asks libc) | log text / an unset `HOME` only |
44
44
  | D16 | A `res` goes out in 32 KiB WebSocket fragments, TS sends one frame: the server's `ws` reassembles before parsing, the 8 MiB cap is per message, and pings can interleave so a slow uplink doesn't starve the pong | wire framing only; pinned by the fragment-count proto check |
45
+ | D17 | Windows cancellation / timeout stops the whole Job Object. Go also observes shell exit independently of output EOF and immediately closes the job, terminating background descendants before draining stdout / stderr. TS's win32 `execAsync` kills only cmd.exe, so workers can survive or keep output pipes open. Go preserves `Command timed out after <ms>ms` and `Command failed: <command>` rather than Node's wrapped `chcp …` error text, and decodes failure output as UTF-8 / GBK too (TS only decodes successful output) | Intentional Windows lifecycle / output correctness; native cancel, timeout, shell-exit and decoder tests. The real TS fix belongs in sandbox.ts's win32 exec branch |
46
+ | D18 | Windows node paths using `\\?\` / `\\.\` namespace or device prefixes (including slash variants), or any component ending in a dot or space other than `.` / `..`, are refused rather than emulated. The same restrictions apply to resolved paths, including shared roots and junction targets, in folder and whole-machine modes | Ordinary Win32 opens can normalize these spellings to a different object after realpath validation. Go rejects them with a node refusal (the existing outside-root message in folder mode); TS does not. Native read/write regressions cover distinct `inside.` / `inside ` directories beside an outward `inside` junction |
47
+
48
+ Windows implementation: the shell starts suspended, is assigned to a `KILL_ON_JOB_CLOSE` job, then resumes. Cancel and timeout call `TerminateJobObject` (no graceful SIGTERM equivalent for hidden cmd.exe), retaining the shared 2 s escalation / 200 ms force-settle ceiling. The byte-capped stdout / stderr streams decode as strict UTF-8, falling back to GBK. Folder checks use case-insensitive Windows `filepath.Rel`, reject other volumes, and resolve the longest existing prefix with `GetFinalPathNameByHandle` (including junctions). A bootstrap cmd.exe gets a new hidden console, runs `chcp 65001`, then starts the command's cmd.exe so it initializes in UTF-8 even without an interactive desktop. The Unicode command passes through `HALO_NODE_COMMAND` in the child environment (delayed expansion in the bootstrap only; the inner `/d /s /c` shell inherits the machine/user `DelayedExpansion` default, like TS), not the bootstrap's legacy-codepage parser. Win32 filesystem errors map through libuv's Node-style errno names. `~/` uses `USERPROFILE`; `~\` stays literal, matching TS.
49
+
50
+ CI keeps the Linux TS-oracle conformance / rm-guard vectors unchanged and adds native Windows vet, tests and exe build. The POSIX conformance fixtures (sh commands, chmod, Unix paths and symlink assumptions) are not run on Windows; native Windows tests cover those platform-specific boundaries instead. The POSIX rm-guard tests are build-excluded there because neither client calls the guard on Windows.
45
51
 
46
52
  ## Two modes
47
53
 
@@ -71,7 +71,7 @@ Full format in [storage.md](storage.md#session-files-20).
71
71
 
72
72
  **Stale anchors are refused (`'archived'`).** `userOrdinal` is positional over the log both sides can see, so it only means anything while client and server agree where the log *starts*. Archiving moves that start forward, and a chat panel open across the compact still counts from the pre-archive top — the same ordinal then maps onto a different turn on the server. The payload now carries the anchor: the client sends `archiveCount`, the archived-segment count its view was opened against. `deleteExchange` counts the committed segments on disk (`countArchiveSegments`, not memory — the segment files are the truth about what was archived) and refuses with `'archived'` only when the two differ; a session with archives can still be deleted from as long as the anchor matches. The WS layer surfaces the refusal as an error frame with `code: 'archived'` (see [ws.md](ws.md)); the admin renders the message verbatim, without the `Error:` prefix it adds to unexpected failures.
73
73
 
74
- **Memory/disk sync.** A live session mutates `agent.messages` in place then `saveAgentState`; a cold session's logs are read, edited and rewritten whole (atomic, through the same writers; a 1.x session is migrated first by that read). Rejected with `running` / `compacting` while a turn is in flight (mutating raw mid-turn would corrupt the in-flight conversation). Refresh is push-based: the active session gets a `state:snapshot`, other open sessions pick it up via the existing `.halo/sessions/` file watcher — no new WS message. Entry point: WS `exchange:delete` → `handler.ts:handleExchangeDelete` (see [ws.md](ws.md)).
74
+ **Memory/disk sync.** A live session mutates `agent.messages` in place then `saveAgentState`; a cold session's logs are read, edited and rewritten whole (atomic, through the same writers; a 1.x session is migrated first by that read). Rejected with `running` / `compacting` while a turn is in flight (mutating raw mid-turn would corrupt the in-flight conversation). Refresh is push-based: the connection subscribed to the session gets `exchange:deleted` (the deleted row ids + the turn's ordinal — not the whole log), other open sessions pick it up via the existing `.halo/sessions/` file watcher. Entry point: WS `exchange:delete` → `handler.ts:handleExchangeDelete` (see [ws.md](ws.md)).
75
75
 
76
76
  ## SessionManager
77
77
 
@@ -509,7 +509,7 @@ Bytes, not an exchange count, because bytes are the actual problem — file size
509
509
 
510
510
  Segments are immutable. Deletion is filesystem-driven: deleting a session lists its directory and removes every file of the session (`ui.jsonl`, `raw.jsonl`, `ui.arch.*`, leftover `.tmp.*`, 1.x `.json` / `arch.*.json.gz`) rather than trusting any count or in-memory map, so crash-orphaned segments go too.
511
511
 
512
- **Read side (scroll-up loading).** The admin loads a session's whole active UI log and fetches segments only when the user scrolls to the top: `GET /api/sessions/logs/:id/archive/:n?projectId=` returns `{ messages }` for one whole segment, gunzipped server-side (`:id` is the encoded full id, so a sub-session's segments are reachable too). The anchor is `archiveCount`, delivered on the `state:snapshot` of **subscribe / reattach only** (one segment count per session open); per-turn snapshots and the post-`exchange:delete` snapshot deliberately omit it. The client walks `archiveCount` → 1 and stops at "no earlier messages"; pulled segments are cached (immutable, so never re-requested) and a failed fetch keeps the cursor so the same segment can be retried.
512
+ **Read side (scroll-up loading).** The admin loads a session's whole active UI log and fetches segments only when the user scrolls to the top: `GET /api/sessions/logs/:id/archive/:n?projectId=` returns `{ messages }` for one whole segment, gunzipped server-side (`:id` is the encoded full id, so a sub-session's segments are reachable too). The anchor is `archiveCount`, delivered on the `state:snapshot` of **subscribe / reattach only** (one segment count per session open); per-turn snapshots deliberately omit it (`exchange:delete` answers with `exchange:deleted`, no snapshot). The client walks `archiveCount` → 1 and stops at "no earlier messages"; pulled segments are cached (immutable, so never re-requested) and a failed fetch keeps the cursor so the same segment can be retried.
513
513
 
514
514
  **The anchor moves up, never down.** `noteArchiveAnchor` no-ops for an equal or lower count (keeping per-turn snapshots — which omit the field, read as 0 — and ordinary re-subscribes idempotent), but a **higher** count re-anchors. Needed because a turn that ends while the session is open can archive a new segment, and the next subscribe/reattach snapshot both carries the higher count *and* replaces the view with the shrunken active log: with a permanently pinned anchor those just-archived turns were in neither place — gone from the view and below a cursor counting down from the old anchor — showing a hole until the user reopened the session. Re-anchoring restarts the walk instead of extending it (prepends are oldest-first, so a newer segment can't splice onto an already-pulled tail); the re-pull costs no requests because `segmentCache` still holds them. Archived history renders collapsed and **read-only** — no Delete button, which matches `deleteExchange` refusing archived logs.
515
515
 
@@ -1,8 +1,8 @@
1
1
  # On-device speech-to-text (`stt-local`)
2
2
 
3
- A canvas extension that declares capability `stt-local` can stream microphone audio to the Halo server and get back text. Everything runs on the server: SenseVoice-Small (int8) decodes, Silero VAD cuts the audio into segments, and `sherpa-onnx-node` hosts both models. There is no cloud call and no credential to configure. Model files are downloaded the first time they're needed. Not mounted in AgentCore mode, since that mode runs neither the extension watcher nor any WS surface.
3
+ A canvas extension that declares capability `stt-local` can stream microphone audio to the Halo server and get back text. Everything runs on the server: SenseVoice-Small (int8) decodes, Silero VAD cuts the audio into segments, optionally pyannote segmentation + CAM++ label who spoke (`speakers=1`), and `sherpa-onnx-node` hosts all of them. There is no cloud call and no credential to configure. Model files are downloaded the first time they're needed. Not mounted in AgentCore mode, since that mode runs neither the extension watcher nor any WS surface.
4
4
 
5
- Code: `packages/server/src/stt/models.ts` (model files + downloader), `stt/engine.ts` (recognizer, VAD, decode queue), `routes/stt-ws.ts` (WS route), `extensions/watcher.ts` (`reconcileSttModels`), mounted in `index.ts` next to the Transcribe proxy.
5
+ Code: `packages/server/src/stt/models.ts` (model files + downloader), `stt/engine.ts` (recognizer, VAD, decode queue, diarizer), `stt/speakers.ts` (speaker matching + split, pure), `routes/stt-ws.ts` (WS route), `extensions/watcher.ts` (`reconcileSttModels`), mounted in `index.ts` next to the Transcribe proxy.
6
6
 
7
7
  ## Models and licenses
8
8
 
@@ -11,6 +11,10 @@ Code: `packages/server/src/stt/models.ts` (model files + downloader), `stt/engin
11
11
  | `sense-voice-zh-en-ja-ko-yue-2024-07-17/model.int8.onnx` | 239233841 | SenseVoice-Small int8 (FunAudioLLM / Alibaba), sherpa-onnx export | [FunASR Model License v1.1](https://github.com/modelscope/FunASR/blob/main/MODEL_LICENSE) |
12
12
  | `sense-voice-zh-en-ja-ko-yue-2024-07-17/tokens.txt` | 315894 | its token table | same |
13
13
  | `silero-vad-v4/silero_vad.onnx` | 1807522 | Silero VAD v4 | MIT |
14
+ | `pyannote-segmentation-3-0/model.int8.onnx` | 1540506 | pyannote/segmentation-3.0 (© CNRS), sherpa-onnx int8 export | MIT |
15
+ | `campplus-zh-en-advanced/3dspeaker_speech_campplus_sv_zh_en_16k-common_advanced.onnx` | 28281164 | 3D-Speaker CAM++ zh/en speaker embedding (Alibaba) | Apache-2.0 |
16
+
17
+ All five are one download set (~271 MB): an install that only uses dictation still fetches the two speaker files (~30 MB) — one state machine, one progress bar, accepted. The speaker models are only *loaded* by a `speakers=1` stream.
14
18
 
15
19
  - **FunASR Model License v1.1** allows free use, copying, modification and sharing, provided you credit the source and the authors and keep the model's name (§2.2). The attribution goes on the **halo-hub extension's description page**. The hub ships no weights: the server pulls them from the sources below, the extension never does.
16
20
  - The VAD is the v4 export (1.8 MB). The 643 KB export the first evaluation used exists only on GitHub releases, not on HF or ModelScope. On sherpa's `test_silero_vad.wav`, the two cut identical segments.
@@ -20,8 +24,9 @@ Code: `packages/server/src/stt/models.ts` (model files + downloader), `stt/engin
20
24
 
21
25
  Each file has its sha256 and size pinned in `STT_MODEL_FILES` (`models.ts`), and response headers are never trusted. Each file tries its sources in order, falling back file by file:
22
26
 
23
- 1. **Hugging Face**, pinned to a commit: `csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17@2365baea…` and `csukuangfj/vad@fba88cd2…`.
24
- 2. **ModelScope** `https://www.modelscope.cn/api/v1/models/<repo>/repo?Revision=master&FilePath=<file>` (`pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue`, `pengzhendong/silero-vad` `v4/silero_vad.onnx`). Only this API form 302s to a CDN that honours Range. `resolve/master` returns a full 200 to a Range request.
27
+ 1. **Hugging Face**, pinned to a commit: `csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17@2365baea…`, `csukuangfj/vad@fba88cd2…`, `csukuangfj/sherpa-onnx-pyannote-segmentation-3-0@9403a690…`, `csukuangfj/speaker-embedding-models@0743f301…`.
28
+ 2. **ModelScope** `https://www.modelscope.cn/api/v1/models/<repo>/repo?Revision=master&FilePath=<file>` (`pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue`, `pengzhendong/silero-vad` `v4/silero_vad.onnx`, `pengzhendong/sherpa-onnx-pyannote-segmentation-3-0`, `lijiu09/speaker-embedding-models`). Only this API form 302s to a CDN that honours Range. `resolve/master` returns a full 200 to a Range request.
29
+ 3. **GitHub releases** (CAM++ only): `k2-fsa/sherpa-onnx` release `speaker-recongition-models` (sic), honours Range.
25
30
 
26
31
  Mechanics:
27
32
 
@@ -32,7 +37,7 @@ Mechanics:
32
37
  - **Single flight**: one `ensure()` promise. Concurrent triggers join it.
33
38
  - **Shutdown** (`gracefulShutdown` → `stt.close()`) aborts the running request and keeps the `.part`. The next boot resumes it (logged as `resuming at N bytes`).
34
39
 
35
- Measured on the dev box (temp HOME): HF, all three files, 3.5 s total (228 MB model in 2.5 s). ModelScope-only (HF forced to fail): tokens + VAD in 2.4 s. Model aborted at 43 MB, then resumed from ModelScope in 10.9 s. SIGTERM mid-download then reboot resumed from HF in 2.3 s. Every sha matched.
40
+ Measured on the dev box (temp HOME), before the two speaker files were added: HF, all three files then in the set, 3.5 s total (228 MB model in 2.5 s). ModelScope-only (HF forced to fail): tokens + VAD in 2.4 s. Model aborted at 43 MB, then resumed from ModelScope in 10.9 s. SIGTERM mid-download then reboot resumed from HF in 2.3 s. Every sha matched.
36
41
 
37
42
  ### State machine
38
43
 
@@ -50,7 +55,7 @@ missing ──ensure()──▶ downloading{received,total} ──all files ok
50
55
  ### Triggers
51
56
 
52
57
  - **WS connect** (`routes/stt-ws.ts`): if the model isn't ready, `ensure()` runs, and that socket then receives `model` frames until `ready` or `error`.
53
- - **Extensions reconcile** (`extensions/watcher.ts` → `reconcileSttModels`): after `start()`'s first scan and after every `rescanAndBroadcast()` scan (install / uninstall / watcher event). If any valid, switched-on extension declares `stt-local` and the state is neither `ready` nor `downloading`, it calls `ensure()`. This makes installing the extension the moment the ~240 MB fetch starts, and it retries an `error` on the next rescan. The watcher doesn't run in AgentCore mode.
58
+ - **Extensions reconcile** (`extensions/watcher.ts` → `reconcileSttModels`): after `start()`'s first scan and after every `rescanAndBroadcast()` scan (install / uninstall / watcher event). If any valid, switched-on extension declares `stt-local` and the state is neither `ready` nor `downloading`, it calls `ensure()`. This makes installing the extension the moment the ~271 MB fetch starts, and it retries an `error` on the next rescan. The watcher doesn't run in AgentCore mode.
54
59
 
55
60
  ## Engine
56
61
 
@@ -59,6 +64,19 @@ missing ──ensure()──▶ downloading{received,total} ──all files ok
59
64
  - **Global decode queue**: FIFO, concurrency 1 server-wide, via `decodeAsync` (libuv pool; the event loop keeps running). 10 s of speech decodes in ~0.2–0.3 s.
60
65
  - **Caps → `busy`**: more than **32** segments waiting server-wide, or more than **60 s** of one stream's audio queued or decoding. The stream fails, and its own queued segments are dropped.
61
66
  - Results: `lang` `<|zh|>` → `zh` (`zh` / `en` / `yue` / `ja` / `ko`). A segment whose text is empty after trimming is dropped (no frame), but still counts in the segment log.
67
+ - **Speaker diarizer** (`speakers=1` streams only): `OfflineSpeakerDiarization` (pyannote seg int8 + CAM++) and one `SpeakerEmbeddingExtractor` (CAM++), created by the first `load({ speakers: true })`, then resident like the recognizer. A plain stream never loads them. Both constructors are sync (~0.6 s together, once per process). A load failure is `SttError('engine')` (`speaker labelling unavailable: …`) on that stream, retried on the next; the recognizer and plain streams are unaffected. Measured on the dev box: **+86 MB RSS** at load; after a 57 s four-speaker session the process sat at ~610 MB vs ~480 MB without speakers (onnxruntime arenas), growing ~15–20 MB per further session like the plain path does.
68
+ - `sherpa-onnx-node`'s diarization class only has a sync `process()`. The engine calls the addon's `offlineSpeakerDiarizationProcessAsync(handle, samples, progressCb)` instead (exactly 3 args; libuv pool, same segments), imported from `sherpa-onnx-node/addon.js` — the package has no `exports` map, and the ESM `default` doesn't expose the addon. The call is wrapped in a 60 s timeout (see Known limits). An `abort()` during a segment's labelling stops before the next embedding.
69
+
70
+ ### Speaker labels (`speakers=1`)
71
+
72
+ Per VAD segment, inside the same FIFO job as its decode (so a stream's finals stay in order), only when the decoded text is non-empty:
73
+
74
+ 1. **Diarize the segment** (`clustering: { numClusters: -1, threshold: 0.6 }`, `minDurationOn 0.3`, `minDurationOff 0.5`) → local runs with local cluster ids. A segment's VAD cut often holds several people back to back (four.wav's first segment is 0.61–18.03 s, three speakers), which is why a per-segment embedding alone isn't enough (it found 3 of 4 speakers).
75
+ 2. **Local cluster → connection speaker** (`stt/speakers.ts` `createSpeakerTracker`): one CAM++ embedding per cluster, from its runs' audio back to back, capped at **6 s** (`compute` is sync on the event loop: ~9 ms per 0.5 s, ~94 ms per 10 s; the engine yields before each one). Longest cluster first. Cosine against the connection's centroids (running sums of unit embeddings): best **≥ 0.55** → that speaker, centroid updated; else a new speaker. A cluster with **< 1.0 s** of audio never founds a new speaker once one exists: it joins the nearest without moving its centroid. Nothing diarized (pyannote found no speech) → the whole segment is one run with one embedding.
76
+ 3. **Text → runs** (`splitBySpeakerRuns`): SenseVoice's `tokens[]` + `timestamps[]` (seconds into the decoded samples; `tokens.join('') === text`, ITN included, checked on zh / en). Each word token goes to the run containing its timestamp (the later-starting one if runs overlap), else the nearest; punctuation / whitespace tokens stay with the current piece, so a piece never starts with `,`. Consecutive same-speaker tokens form one `final`. A new piece starts at its speaker run's start, clamped between the previous piece's last token and its own first token; the first piece starts at the segment's padded start, the last ends at its padded end, so the finals tile the segment without overlap. A piece that would span zero seconds (overlapping runs clamp its start onto its only token) is merged into its neighbour, and same-speaker neighbours left by that merge are joined. Tokens that don't spell the trimmed text (or no timestamps) → the whole text under the speaker with the most audio in the segment, the same rule as the Transcribe proxy's `splitBySpeaker`.
77
+ 4. **Wire label**: `"0"`, `"1"`… numbered per connection in order of first *sent* appearance.
78
+
79
+ Thresholds: CAM++ cosine on four.wav, pairwise over its nine reference turns (all ≥ 1 s, each embedding capped at 6 s), was 0.75–0.83 for the same speaker (6 pairs) and 0.03–0.52 between different ones (30 pairs); in the engine every match threshold from 0.45 to 0.7 gave the same labels, so 0.55 sits in the gap. **Acceptance** (sherpa's 4-speaker zh test `four.wav`, 56.9 s, 16 kHz mono, converted to s16le and streamed through `createSttEngine()` with the real models in 100 ms frames, `speakers` on): 8 finals labelled `0 1 2 | 0 | 3 | 3 | 2 | 0`, matching the reference (`A B B C | A | D | D | C | A`, sherpa's two S1 runs fall in one piece here) up to renaming, the first segment split at 7.03 s and 13.78 s into its three speakers' sentences. Total 3.4 s for 57 s of audio (1.8 s without speakers); longest event-loop gap 70 ms.
62
80
 
63
81
  ### VAD (one per stream)
64
82
 
@@ -81,12 +99,15 @@ Each stream keeps a 30 s ring of raw samples (it must reach back past a 20 s seg
81
99
 
82
100
  `noServer` ws server behind `ws/upgrade-router.ts`, same structure as the Transcribe proxy (`routes/transcribe-ws.ts`).
83
101
 
102
+ - **Query**: `ext=<id>` (required) and optional `speakers=1`. Only the exact value `1` turns speaker labels on (loading the diarizer if this is the first such stream); absent or any other value = no labels, byte-identical frames to before. voice-input never sends it; htrans ≥ 1.0.10 does when "Identify speakers" is ticked with the on-device engine.
103
+
84
104
  - **Handshake**: login cookie (`isAuthenticated(getTokenFromCookieHeader(cookie))`), else **401**. `ext` must be installed, switched on and declare `stt-local`, else **403**. Frames are capped at 1 MiB (`maxPayload`). Every socket has an `error` listener, so a protocol violation (oversize → 1009, bad UTF-8 → 1007) closes only that socket.
85
105
  - **Server → client** (text JSON):
86
106
  - `{"type":"model","state":"missing"|"downloading"|"ready"|"error","received"?,"total"?,"message"?}`: always the **first** frame. While the model isn't ready, more `model` frames follow (progress ≤ 2/s, plus every state change). `received` / `total` are present only for `downloading`, `message` only for `error`.
87
107
  - `{"type":"ready"}`: model ready **and** engine loaded. Audio is accepted from here on.
88
108
  - `{"type":"final","start":<s>,"end":<s>,"text":"…","lang":"zh"|"en"|"yue"|"ja"|"ko"}`: one per non-empty segment. `start` / `end` are seconds (3 decimals) from this connection's first accepted audio sample.
89
- - `{"type":"error","code":"model"|"engine"|"busy"|"bad-request"|"io","message":"…"}`, then close **1011**. `model` = download failed (the reason is also in the preceding `model` frame's `message`), `engine` = native load / VAD / decode failure, `busy` = queue caps above, `bad-request` = a text frame other than `{"type":"end"}`, `io` = anything else.
109
+ - With `speakers=1`, every `final` also carries `"speaker":"0"|"1"|…`: a **string**, numbered per connection in order of first appearance — the same shape as the Transcribe proxy's (htrans shows `Number(speaker) + 1` as "Speaker N"). A segment several people spoke in arrives as **one `final` per speaker run** (see [Speaker labels](#speaker-labels-speakers1)), in time order, non-overlapping, never zero-length, sharing the segment's `lang`; their `start` / `end` tile the segment's padded bounds.
110
+ - `{"type":"error","code":"model"|"engine"|"busy"|"bad-request"|"io","message":"…"}`, then close **1011**. `model` = download failed (the reason is also in the preceding `model` frame's `message`), `engine` = native load / VAD / decode / diarizer load or run failure, `busy` = queue caps above, `bad-request` = a text frame other than `{"type":"end"}`, `io` = anything else.
90
111
  - **Client → server**:
91
112
  - binary = PCM s16le mono 16 kHz, any frame length (an odd trailing byte carries over). **Binary sent before `ready` is dropped**, because there's no stream yet to feed. A client should start sending only after `ready` (`start` / `end` count from the first audio sent after it).
92
113
  - text `{"type":"end"}`: flush the VAD, wait for every remaining segment of this connection to be decoded and its `final` sent, then close **1000**. Sent before `ready`, it closes 1000 at once (nothing was accepted).
@@ -95,12 +116,15 @@ Each stream keeps a 30 s ring of raw samples (it must reach back past a 20 s seg
95
116
 
96
117
  ## Logs
97
118
 
98
- `[STT] …` only, with counts and durations: `stream open ext=…`, `stream closed after Ns ext=… segments=N audio=Ns`, `stream error ext=… code=…`, `engine loaded in Nms (threads=N)` / `engine load failed: …`, `model download start`, `downloaded <file> from <source> (N MB in Ns)`, `download <file> from <source> failed: <reason>`, `<file>: resuming at N bytes`. **Recognized text and audio are never logged.** The server's default level is `warn`, so these `console.log` lines show up at `general.logging.level: info` (or `HALO_LOG_LEVEL=info`).
119
+ `[STT] …` only, with counts and durations: `stream open ext=…[ speakers=1]`, `stream closed after Ns ext=… segments=N audio=Ns[ speakers=N]`, `stream error ext=… code=…`, `engine loaded in Nms (threads=N)` / `engine load failed: …`, `speaker diarizer loaded in Nms` / `speaker diarizer load failed: …`, `model download start`, `downloaded <file> from <source> (N MB in Ns)`, `download <file> from <source> failed: <reason>`, `<file>: resuming at N bytes`. **Recognized text and audio are never logged.** The server's default level is `warn`, so these `console.log` lines show up at `general.logging.level: info` (or `HALO_LOG_LEVEL=info`).
99
120
 
100
121
  ## Known limits
101
122
 
102
- - **Proxies**: neither `fetch` nor `node:http(s)` reads `HTTPS_PROXY` / `HTTP_PROXY` on Node 22 by default (verified: with an unreachable `HTTPS_PROXY`, both connected directly). `NODE_USE_ENV_PROXY=1` (experimental in Node 22, prints `UNDICI-EHPA` warnings) makes both honour it. A server behind a mandatory egress proxy can't download the models without it. The workaround is to drop the three files into `~/.halo/global/stt-models/` by hand with the exact names and sizes above.
103
- - ~400 MB resident once any stream has loaded the engine (no idle release, see above). The decode burst uses up to 4 cores.
123
+ - **Proxies**: neither `fetch` nor `node:http(s)` reads `HTTPS_PROXY` / `HTTP_PROXY` on Node 22 by default (verified: with an unreachable `HTTPS_PROXY`, both connected directly). `NODE_USE_ENV_PROXY=1` (experimental in Node 22, prints `UNDICI-EHPA` warnings) makes both honour it. A server behind a mandatory egress proxy can't download the models without it. The workaround is to drop the five files into `~/.halo/global/stt-models/` by hand with the exact names and sizes above.
124
+ - ~400 MB resident once any stream has loaded the engine (no idle release, see above), plus ~90–130 MB once a `speakers=1` stream has loaded the diarizer. The decode burst uses up to 4 cores.
125
+ - **Speaker labels are per connection**: a reconnect (htrans's Pause → Resume starts a new run) renumbers from `"0"`, and the same person may get a different number. Labels are never names. Overlapping speech, far-field / room microphones, telephone audio and more than ~4 speakers are **unmeasured** — the only acceptance file is sherpa's clean four-speaker zh test. A speaker whose only turns are under 1 s is folded into the nearest existing one. A speaker change inside a SenseVoice token (rare; tokens are ~0.1–0.3 s) is cut at the token boundary.
126
+ - **The direct-wav acceptance above is the optimistic case.** Through a real browser capture path (htrans browser e2e: Chrome captures the 16 kHz source at 48 kHz, the htrans worklet downsamples back to 16 kHz) one 2.5 s stretch of four.wav (11.2–13.8 s, reference speaker B) was labelled as another speaker in every run, and one run produced a 5th speaker for a misrecognised D sentence. The same 48 → 16 kHz round trip done with ffmpeg reproduces it, and real microphones always go through this path.
127
+ - **Diarization timeout**: one native diarization call is capped at 60 s (`DIAR_TIMEOUT_MS`), then the stream fails `engine` (`speaker diarization failed: timed out`). sherpa-onnx-node 1.13.8's async diarization worker has no `OnError`, so a native exception would otherwise leave its promise pending and stall the server-wide decode queue until restart. The native call itself can't be cancelled: if it is really still running, it keeps one libuv thread busy until it returns.
104
128
  - One decode at a time: with many concurrent speakers, latency grows linearly until `busy`.
105
129
  - No partial results (SenseVoice is non-streaming): a final arrives ~0.5 s after the speaker pauses, at most 20 s after a segment starts.
106
130
  - Desktop (win-x64 / darwin-arm64): `stage-runtime.mjs` `installSherpaBinary()` ships the target's `sherpa-onnx-<platform>-<arch>` package (see [dev/desktop-packaging.md](../dev/desktop-packaging.md)). A build without it fails streams with `engine`, never server boot. The macOS addon declares minimum 14.0; older macOS is unverified.
@@ -9,6 +9,7 @@ File: `packages/server/src/ws/handler.ts`
9
9
  - Endpoint: `ws://localhost:9527/ws`
10
10
  - Auth: `verifyClient` callback validates the JWT cookie
11
11
  - All messages are JSON-encoded
12
+ - Compression: permessage-deflate (`perMessageDeflate: { zlibDeflateOptions: { level: 1 }, threshold: 1024 }` in `index.ts`) — frames ≥ 1 KB are compressed when the client offers the extension (browsers do), so a multi-MB subscribe snapshot ships ~4× smaller; stream deltas stay raw. Context takeover is on (~300 KB of zlib state per connection, allocated on the first compressed frame). Only the admin `/ws` server; the node / transcribe / stt / jupyter upgrade routes don't compress
12
13
 
13
14
  ### Server-side keepalive
14
15
 
@@ -66,7 +67,7 @@ Both directions are typed in `packages/core/src/protocol/ws-frames.ts` (`WsClien
66
67
  | `chat:stop` | Hard-abort the current generation (ends the turn, no re-run) → `stopUserSession`. During a manual `/compact` (compacting, no turn running) it cancels the compact instead (`cancelCompact`, history rolls back). During an auto-compact — which runs inside a turn and is not cancellable — the abort takes effect once the compact finishes. |
67
68
  | `chat:interrupt` | Interrupt the in-flight turn now (aborts a command mid-run); the server then folds any queued messages into one follow-up turn → `interruptSession`. Admin chat esc maps to this. Same compact semantics as `chat:stop`: a manual `/compact` with no turn running is cancelled; an auto-compact finishes first, then the interrupt lands. |
68
69
  | `session:delete` | Delete session files + cascade-delete descendants in SQLite (handled inline), then release this connection's listener for it (no save — the session is gone) |
69
- | `exchange:delete` | Delete one exchange (a user turn + its responses): **soft-delete** in the UI log (`deleted: true` markers, kept visible/greyed) + **physical-delete** the turn's copy from `rawMessages` (LLM context; located by the row's `stamp` — a row from before stamps by its text, among old rows and turns only) → `deleteExchange`. Fields: `userOrdinal` (0-based index among *main-conversation* user turns, i.e. excluding `taskId` sub-agent messages — matches the admin's `isMainConversationMessage` count), `archiveCount` (the archived-segment count the client's view was opened against — its archive anchor, from the `state:snapshot` it got on subscribe), optional `sessionId` / `projectId`. Rejected (→ `error`) while the session is running or compacting. The server also compares `archiveCount` against the archive segments on disk (`countArchiveSegments`); only a **mismatch** is refused, with `code: 'archived'` ("… archived history since it was opened — reopen it to delete individual turns") — a session with archives can still be deleted from as long as the anchor matches. A turn after the last compaction whose raw copy can't be located (a stamped row whose block raw doesn't hold — never a text fallback; an unstamped row merged with others, or its text not found), a stamp shared by two rows or two raw blocks, or a summary row is refused with `code: 'raw_unmatched'`, nothing changed. See [session.md](session.md#exchange-deletion-soft-ui--hard-raw). |
70
+ | `exchange:delete` | Delete one exchange (a user turn + its responses): **soft-delete** in the UI log (`deleted: true` markers, kept visible/greyed) + **physical-delete** the turn's copy from `rawMessages` (LLM context; located by the row's `stamp` — a row from before stamps by its text, among old rows and turns only) → `deleteExchange`. Fields: `userOrdinal` (0-based index among *main-conversation* user turns, i.e. excluding `taskId` sub-agent messages — matches the admin's `isMainConversationMessage` count), `archiveCount` (the archived-segment count the client's view was opened against — its archive anchor, from the `state:snapshot` it got on subscribe), optional `sessionId` / `projectId`. On success the server answers this connection — when it is subscribed to the session — with `exchange:deleted` (ids only, no snapshot). Rejected (→ `error`) while the session is running or compacting. The server also compares `archiveCount` against the archive segments on disk (`countArchiveSegments`); only a **mismatch** is refused, with `code: 'archived'` ("… archived history since it was opened — reopen it to delete individual turns") — a session with archives can still be deleted from as long as the anchor matches. A turn after the last compaction whose raw copy can't be located (a stamped row whose block raw doesn't hold — never a text fallback; an unstamped row merged with others, or its text not found), a stamp shared by two rows or two raw blocks, or a summary row is refused with `code: 'raw_unmatched'`, nothing changed. See [session.md](session.md#exchange-deletion-soft-ui--hard-raw). |
70
71
  | `command:<name>` | Route through shared `dispatchCommand` (see [command.md](command.md)); `/session compact` handled inline for UI callbacks |
71
72
  | `terminal:start` | Spawn a new PTY in `workspacePath`, named `name` (the tab name every page shows); pushed to the workspace's other subscribers as `terminal:opened` |
72
73
  | `terminal:input` | Send keystrokes; the sender becomes the PTY's size driver (PTY switches to the size it last reported). `terminalId` is **required** — an id-less frame is logged and dropped (it used to fall back to the first entry of the process-global terminal map, which could write into a PTY of another workspace) |
@@ -127,7 +128,8 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
127
128
 
128
129
  | Type | Source | Purpose |
129
130
  |---|---|---|
130
- | `state:snapshot` | handler.ts on connect | Initial state (agents, messages, sessionId). On **subscribe / reattach only** it also carries `archiveCount` — the number of committed UI-log archive segments, which the admin's scroll-up loader counts down from (see [Archive anchor](#archive-anchor-in-statesnapshot)) — and `accessLevel` (`'workspace' | 'readonly' | null`, null = full), which seeds the admin's access-level selector. Subscribe includes `accessLevel` only when the session already exists; for a not-yet-created session it's omitted, and the admin leaves the selector alone when the field is absent (so a level picked before the first send isn't reset) |
131
+ | `state:snapshot` | handler.ts on connect | Initial state (agents, messages, sessionId). On **subscribe / reattach only** it also carries `archiveCount` — the number of committed UI-log archive segments, which the admin's scroll-up loader counts down from (see [Archive anchor](#archive-anchor-in-statesnapshot)) — and `accessLevel` (`'workspace' | 'readonly' | null`, null = full), which seeds the admin's access-level selector. Subscribe includes `accessLevel` only when the session already exists; for a not-yet-created session it's omitted, and the admin leaves the selector alone when the field is absent (so a level picked before the first send isn't reset). The subscribe / reattach rows leave out the log's `tool_call` / `tool_result` rows (`adminSnapshotRows`) — the chat renders each tool call from the assistant row's `contentBlocks`; usage / context rows stay. The on-disk UI log and the other channels keep them. The admin reuses the objects of unchanged rows on replace (`reconcileMessages`), so memoized rows skip re-render |
132
+ | `exchange:deleted` | `handleExchangeDelete` (this client, when subscribed to the session) | An `exchange:delete` landed — `{sessionId, deletedIds, userOrdinal, userRows}`: `deletedIds` is every soft-deleted row id of the active log (idempotent), `userOrdinal` the turn just deleted, `userRows` the log's user rows without `taskId` (deleted ones included — the count the ordinal runs over). The admin flags those rows plus the `userOrdinal` turn's span (rows this tab created live carry client ids the server log doesn't know) — no log replace. When the tab's own count differs from `userRows` (a turn another connection sent with `localEcho`, a `sendFailed` bubble) it flags the ids only and re-sends `subscribe`, whose snapshot corrects the log |
131
133
  | `chat:ack` | `handleChat` | Chat with `clientMsgId` is persisted in the session log — releases the client's pending-ack entry (see [Chat delivery](#chat-delivery-ack--resend--dedup)) |
132
134
  | `__pong__` | `__ping__` handler | Reply to the client's application-level liveness probe |
133
135
  | `listener:released` | `reclaimIfAbandoned` (this client only) | One of this connection's event listeners was reclaimed (silent >3 min / CLOSED) — `{sessionId}` (never null), one frame per released session. Client must re-`subscribe` to reattach; the admin does so only for the tab on screen. See [Abandoned-listener reclaim](#abandoned-listener-reclaim-and-the-__ping__-contract). |
@@ -141,7 +143,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
141
143
  | `chat:stopped` | `chat:stop` / `chat:interrupt` handlers (this client only) | Stop/interrupt acknowledged — `{sessionId}` |
142
144
  | `session:compacted` | compact handler | Compaction complete — `{message?, contextTokens, sessionId}` |
143
145
  | `compact:started` / `compact:summarizing` / `compact:done` | compact handler; `ws/event-processor.ts` | Compaction progress, `{sessionId}`. An auto-compact whose LLM summary failed falls back to a local compact and closes with a `system` event carrying `compactEnd`, which also emits `compact:done` (the success path closes via `compacted`). |
144
- | `cron:job_changed` / `cron:run_changed` | `cron/runner.ts` + `routes/cron.ts` (broadcast) | Cron job/run state changed — the Cron tab re-fetches instead of polling. `cron:job_changed`: `{jobId, kind?}` (`created` / `updated` / `deleted` / `reconciled` / `missed`; a run's finalize sends `lastRunStatus` + `lastRunAt` instead), plus — whenever the job still exists, except `missed` — `workingDir` (workspace-relative, `null` = root), `stateless` (boolean) and `sessionAgentId` (agent of the job's existing session, `null` when stateless / not created / unreadable; the job's agent is locked to it). `cron:run_changed`: `{jobId, runId, status}`; `status: 'deleted'` = that run row was withdrawn (a webhook fire's re-check found the session taken by another owner) — clients just re-fetch the list. See [cron.md](cron.md). |
146
+ | `cron:job_changed` / `cron:run_changed` | `cron/runner.ts` + `routes/cron.ts` (broadcast) | Cron job/run state changed — the Triggers (cron) tab re-fetches instead of polling. `cron:job_changed`: `{jobId, kind?}` (`created` / `updated` / `deleted` / `reconciled` / `missed`; a run's finalize sends `lastRunStatus` + `lastRunAt` instead), plus — whenever the job still exists, except `missed` — `workingDir` (workspace-relative, `null` = root), `stateless` (boolean) and `sessionAgentId` (agent of the job's existing session, `null` when stateless / not created / unreadable; the job's agent is locked to it). `cron:run_changed`: `{jobId, runId, status}`; `status: 'deleted'` = that run row was withdrawn (a webhook fire's re-check found the session taken by another owner) — clients just re-fetch the list. See [cron.md](cron.md). |
145
147
  | `evolution:run_changed` / `evolution:apply_changed` | `evolution/ticker.ts` + `routes/evolution.ts` (broadcast) | Evolution run/apply state changed — drives the Evolution tab. Includes the L2/L3 auto path (`running → approved` plus the new `approved_by='auto'` apply, pushed by the ticker's diff) and `rolled_back` (pushed by the rollback route). See [evolution.md](evolution.md). |
146
148
  | `models:changed` | `models/watcher.ts` `reloadAndBroadcast` (broadcast to all clients) | `~/.halo/global/models.d/` changed (hub provider yamls installed by `halo models install` / `/extension models`, or removed by hand) and the merged registry differs from the last one — the server has already dropped its registry cache. No payload: the admin bumps its models bus, and the Agents panel / agent form, the composer's image-capability check and the Settings page re-fetch `/agent-configs/models` / the settings schema. Also bumped on WS reconnect (a frame lost while down). Global, no workspace marker. See [storage.md](storage.md#model-registry-format). |
147
149
  | `skills:changed` | `ws/skills-changed.ts` `notifySkillsChanged` (REST `POST` / `DELETE /skills`, `PATCH /skills/:id/toggle`, `/skill disable` · `enable` · `delete`) · `ws/watcher-pool.ts` (the workspace watcher saw `.halo/skills/**` change) | A skill was created / deleted / toggled, or a workspace skill folder changed on disk (an agent's `file_write`, a hand edit). No payload: the admin bumps its skill bus, and the chat slash-command popup, the Skills page and the agent form's skill picker re-fetch. Scoped on the server: a workspace skill or a toggle (the disable flag lives in that workspace's db) goes via `broadcastToWorkspace`, a global skill via `broadcast`. Only real changes emit: a no-op mutation (409 / 404 / already disabled) sends nothing, and the watcher path compares a per-workspace key (folder ids + each SKILL.md's frontmatter), so body / resource edits and the echo of a mutation that already notified stay silent. The watcher debounce is 300 ms on top of the file watcher's own 300 ms flush. `~/.halo/global/skills/` has no watcher, so a hand edit there shows on the Skills page's window-focus refetch. Also bumped on WS reconnect while the Skills page is open. |
@@ -155,7 +157,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
155
157
 
156
158
  ### Archive anchor in `state:snapshot`
157
159
 
158
- `archiveCount` rides only the snapshots sent on **subscribe** and **reattach** — one segment count per session open. The per-turn snapshots and the snapshot pushed after `exchange:delete` deliberately omit it: the client's "load older" cursor is an anchor pinned at the moment the session was opened, and a segment written mid-session (a compact while the tab is open) must not move it. The client then requests segments over HTTP (`GET /api/sessions/logs/:id/archive/:n`), not WS — one whole segment per call, cached client-side because segments are immutable. See [session.md](session.md#ui-log-archiving).
160
+ `archiveCount` rides only the snapshots sent on **subscribe** and **reattach** — one segment count per session open. The per-turn snapshots deliberately omit it (and `exchange:delete` answers with `exchange:deleted`, no snapshot): the client's "load older" cursor is an anchor pinned at the moment the session was opened, and a segment written mid-session (a compact while the tab is open) must not move it. The client then requests segments over HTTP (`GET /api/sessions/logs/:id/archive/:n`), not WS — one whole segment per call, cached client-side because segments are immutable. See [session.md](session.md#ui-log-archiving).
159
161
 
160
162
  The same anchor is what the client sends back as `archiveCount` on `exchange:delete`, so the server can tell a stale ordinal from a live one.
161
163
 
@@ -229,7 +231,7 @@ Teardown lives in one function (`cleanupConnection`) shared by the socket's `clo
229
231
  Client reconnects and sends `subscribe`:
230
232
  1. Server detects the detached session
231
233
  2. Loads UIState from SessionManager (via `getUIState`) and builds a save snapshot
232
- 3. Sends `state:snapshot` — messages from `createSaveSnapshot(state)`, or the settled `messageLog` only when the session is still running (see step 6) — plus `archiveCount`, re-pinning the scroll-up anchor for the reopened log
234
+ 3. Sends `state:snapshot` — messages from `createSaveSnapshot(state)`, or the settled `messageLog` only when the session is still running (see step 6), minus the `tool_call` / `tool_result` rows — plus `archiveCount`, re-pinning the scroll-up anchor for the reopened log
233
235
  4. Replays buffered `pendingEvents`
234
236
  5. Re-attach the event listener — live streaming continues seamlessly
235
237
  6. If the session is still running, synthesizes the in-progress turn as an **authoritative replay**: every synthesized message carries `replay: true` — a `chat:followup`, then the turn's `turnContentBlocks` in order (`chat:thinking` / `chat:stream` / `agent:tool_call` + `agent:tool_result` if completed), each with the session's real `agentName` and the block's real `turnId` so usage badges / turn grouping survive the reconnect. The running-session snapshot in step 3 sends the settled `messageLog` only (no `createSaveSnapshot` temp in-flight message — the replay carries that turn instead).
@@ -98,7 +98,7 @@ Installed extensions are directories under `~/.halo/global/extensions/<id>/` wit
98
98
  | DELETE | `/api/extensions/:id` | cookie | Remove the directory and drop its on / off entry. 400 for an invalid id shape, 404 if not installed. Returns `{ok, id}` |
99
99
  | GET | `/api/extensions/:id/:version/:token/<asset>` | **path token, no cookie** | Static file from the extension directory. 401 unless `verifyScopedToken(token,'ext')`; 404 if `version` ≠ installed version or the asset escapes the directory. Response headers: `Access-Control-Allow-Origin: *`, `Referrer-Policy: no-referrer`, `Cache-Control: public, max-age=31536000, immutable` (the version segment is the cache key) |
100
100
  | WS | `/api/transcribe/stream?ext=<id>&lang=<auto\|xx-XX\|xx-XX,yy-YY,…>[&speakers=1]` | cookie | Streaming transcription proxy to Amazon Transcribe (`routes/transcribe-ws.ts`; not mounted in AgentCore mode). Handshake 401 without the login cookie, 403 unless `ext` is installed, switched on and declares capability `transcribe`. Client → binary PCM s16le mono 16 kHz, text `{"type":"end"}` (flush, server closes 1000). Server → `{"type":"ready"}` (on connect; stream audio right after it), `{"type":"partial"\|"final",start,end,text,lang}` (seconds from the stream's first audio byte); with `speakers=1` (speaker labels, off by default) a `final` also carries `speaker` (`"0"`, `"1"`… — Transcribe's per-stream label, not a name; absent when Transcribe labelled nothing) and a result spoken by several people arrives as one `final` per speaker, `partial` never has it — without `speakers=1` each result is one `final` without `speaker`; `{"type":"error",code,message}` then close; `code` ∈ `credentials` \| `denied` \| `limit` \| `bad-request` \| `io`. Region / languages / credentials from the extension's `ext-<id>` settings (default chain when no keys) — see [design](../design/canvas-extensions.md#streaming-transcription-proxy) |
101
- | WS | `/api/stt/stream?ext=<id>` | cookie | On-device speech-to-text — SenseVoice + Silero VAD via sherpa-onnx, no cloud (`routes/stt-ws.ts`; not mounted in AgentCore mode). Handshake 401 without the login cookie, 403 unless `ext` is installed, switched on and declares capability `stt-local`. Server → `{"type":"model",state,received?,total?,message?}` first (`state` ∈ `missing` \| `downloading` \| `ready` \| `error`; a missing model is downloaded from here, progress pushed ≤ 2/s), `{"type":"ready"}` once model + engine are loaded (binary sent before it is dropped), `{"type":"final",start,end,text,lang}` per VAD segment (seconds from the connection's first accepted sample; `lang` ∈ `zh` \| `en` \| `yue` \| `ja` \| `ko`), `{"type":"error",code,message}` then close 1011; `code` ∈ `model` \| `engine` \| `busy` \| `bad-request` \| `io`. Client → binary PCM s16le mono 16 kHz, text `{"type":"end"}` (remaining finals, then close 1000). No `lang` parameter — see [design/stt-local.md](../design/stt-local.md#ws-apisttstreamextid) |
101
+ | WS | `/api/stt/stream?ext=<id>[&speakers=1]` | cookie | On-device speech-to-text — SenseVoice + Silero VAD via sherpa-onnx, no cloud (`routes/stt-ws.ts`; not mounted in AgentCore mode). Handshake 401 without the login cookie, 403 unless `ext` is installed, switched on and declares capability `stt-local`. Server → `{"type":"model",state,received?,total?,message?}` first (`state` ∈ `missing` \| `downloading` \| `ready` \| `error`; a missing model is downloaded from here, progress pushed ≤ 2/s), `{"type":"ready"}` once model + engine are loaded (binary sent before it is dropped), `{"type":"final",start,end,text,lang}` per VAD segment (seconds from the connection's first accepted sample; `lang` ∈ `zh` \| `en` \| `yue` \| `ja` \| `ko`); with `speakers=1` (on-device speaker labels — only that value; pyannote + CAM++, loaded on the first such stream) every `final` also carries `speaker` (`"0"`, `"1"`… numbered per connection in order of appearance, not a name — same shape as the Transcribe proxy's) and a segment spoken by several people arrives as one `final` per speaker run, in time order, non-overlapping — without it frames are unchanged; `{"type":"error",code,message}` then close 1011; `code` ∈ `model` \| `engine` \| `busy` \| `bad-request` \| `io`. Client → binary PCM s16le mono 16 kHz, text `{"type":"end"}` (remaining finals, then close 1000). No `lang` parameter — see [design/stt-local.md](../design/stt-local.md#ws-apisttstreamextid) |
102
102
  | GET | `/api/jupyter/status` | cookie | Notebook kernels (`routes/jupyter.ts`; not mounted in AgentCore mode). `{ enabled, available, version?, error?, hint? }` — never starts the server; disabled → `enabled:false, available:false`; Jupyter missing → `available:false` + `error` + `hint: 'pip install jupyter_server ipykernel'` |
103
103
  | GET | `/api/jupyter/kernelspecs` | cookie | `{ default, specs: [{ name, displayName, language }] }`. Starts the local jupyter server on demand; 503 `{error, hint?}` when disabled / unavailable / it fails to start |
104
104
  | POST | `/api/jupyter/kernels` | cookie | Body `{ projectId, path, name }` (`path` = workspace-relative notebook path, as in `init.file.path`). 400 for a missing field, unknown project, path outside the workspace or a missing directory. Starts kernel `name` with cwd = the notebook's directory → `{ id, name }` |
@@ -228,10 +228,10 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
228
228
  | Method | Path | Purpose |
229
229
  |---|---|---|
230
230
  | GET | `/api/cron/jobs?limit=&before=` | Cursor-paginated newest-first by `createdAt`. Default `limit` 20, max 100; `before` is a `createdAt` cursor. Returns `{jobs, hasMore, nextCursor}`. Each job has `targets` decoded, a live `nextRunAt` (computed from schedule/runAt, inside the active window), `sessionId` (`null` = runs in the job's own session), `effectiveSessionId` (the session the run actually uses: `sessionId`, else the job's own — same id as the job, `cron-<id>` for a custom id without that prefix; older jobs were pinned to their original `cron-cron-…` session by a cron.db migration), `activeFrom` / `activeUntil` (`null` = unbounded), `workingDir` (workspace-relative folder the run focuses on; `null` = the workspace root), `stateless` (boolean; `true` = a fresh session every run), and `sessionAgentId` (the agent of the effective session once it exists — the job's agent is locked to it; `null` for a stateless job, a session not created yet, or a session that can't be read), `webhookSecret` (`null` unless a webhook job) and `webhookPath` (`/cron-hooks/<id>`, relative — prefix the public origin; `null` unless a webhook job) |
231
- | POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt, schedule, runAt?, webhook?, timezone?, timeoutSec?, sessionId?, workingDir?, stateless?, targets?, enabled?}`. Exactly one of `schedule` (5-field cron), `runAt` (epoch ms, future) or `webhook: true` is required (none → 400 `schedule, runAt or webhook required`; two → 400 `… mutually exclusive`). `webhook` (boolean, else 400) makes it a webhook-triggered job: the server generates its secret; a webhook job takes no `sessionId` (400 `a webhook job runs in its own session …`) and no active window. `timeoutSec` must be an integer 60–21600 (else 400); unset = runner default 3600. `timezone` is an IANA name (e.g. `Asia/Shanghai`); unset / `null` / `''` = the host timezone (see `/api/cron/meta`); a non-string → 400 `timezone must be a string`, a zone `Intl.DateTimeFormat` rejects → 400 `invalid timezone: <value>`. `sessionId` picks an existing (or to-be-created) **root** session of `workspacePath` for the runs to use — `[A-Za-z0-9_:-]{1,200}`, no `>` sub-session path (else 400); unset / `null` / `''` = the job's own session (id = the job id). A picked session that already exists keeps its own access level, and its agent must equal `agentId` (agent lock, below); see [design/cron.md](../design/cron.md#execution-runjob) for the shared-session guards. `activeFrom` / `activeUntil` (epoch ms or `null`, recurring only): the schedule fires only inside `[activeFrom, activeUntil)`; with `runAt` → 400; `activeUntil` must be in the future and after `activeFrom` (else 400); `activeFrom` may be in the past. `workingDir` (workspace-relative or absolute; unset / `null` / `''` = the root) must be an existing directory inside `workspacePath` (else 400 `workingDir: …`); stored workspace-relative. `stateless` (boolean, else 400) runs every fire in a fresh session; with `sessionId` → 400 `stateless (new session every run) and sessionId are mutually exclusive`. Agent lock: when the effective session already exists and runs another agent → 400 `session <id> already runs agent <x>; …`; a session db that can't be read → 400 `could not read session <id> from <ws>: …`. Returns `{ok, id}` |
232
- | PUT | `/api/cron/jobs/:id` | Partial update over the same fields. Re-validates `schedule` / `runAt` and enforces trigger-mode exclusivity on the **merged** row: both in one body → 400 `schedule and runAt are mutually exclusive`; supplying one side is a mode switch that implicitly clears the other; a body that clears the active side without supplying a replacement → 400 `schedule, runAt or webhook required`. `webhook: true` switches to webhook mode (an existing secret is kept; with `schedule` / `runAt` in the same body → 400); `webhook: false`, or a `schedule` / `runAt` alone, switches away and drops the secret. `runAt` must be a finite future epoch-ms number or `null`. `timeoutSec` follows the same partial-body contract: omitted = unchanged, `null` = clear back to the default, a number is range-validated (60–21600) same as create. `timezone` likewise: omitted = unchanged, `null` / `''` = clear back to the host timezone, anything else is validated same as create (same two 400s). `sessionId` likewise: omitted = unchanged, `null` / `''` = back to the job's own session (an older job pinned to `cron-cron-…` then starts a fresh one), a string is validated same as create. `activeFrom` / `activeUntil` likewise (omitted = unchanged, `null` = clear); the window rules are checked on the **merged** row, so switching to `runAt` while a window is stored needs both bounds cleared in the same body, and a newly supplied `activeUntil` must be in the future (a stored past one doesn't block editing other fields). `workingDir` likewise (omitted = unchanged, `null` / `''` = back to the root); a stored folder is re-checked when `workspacePath` changes. `stateless` and the `sessionId` exclusivity are checked on the **merged** row. The agent-lock / read-error 400s (see POST) apply only when the body changes the stored agent, session, `stateless` or `workspacePath` — so an unrelated edit such as `{enabled:false}` always succeeds, even for a job whose session already runs another agent. Flips schedule/unschedule on `enabled`. Returns `{ok}` |
231
+ | POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt?, schedule, runAt?, webhook?, timezone?, timeoutSec?, sessionId?, workingDir?, stateless?, targets?, enabled?}`. Exactly one of `schedule` (5-field cron), `runAt` (epoch ms, future) or `webhook: true` is required (none → 400 `schedule, runAt or webhook required`; two → 400 `… mutually exclusive`). `webhook` (boolean, else 400) makes it a webhook-triggered job: the server generates its secret; a webhook job takes no active window. `userPrompt` is required (else 400 `userPrompt required`) except on a webhook job, where omitted / `''` is stored as `''` and each run opens with its events alone. It may pick a `sessionId` like any stateful job: its events wait while that session is owed to another owner (relay / A2A / an invocation / another job's run) and run once it is released. `timeoutSec` must be an integer 60–21600 (else 400); unset = runner default 3600. `timezone` is an IANA name (e.g. `Asia/Shanghai`); unset / `null` / `''` = the host timezone (see `/api/cron/meta`); a non-string → 400 `timezone must be a string`, a zone `Intl.DateTimeFormat` rejects → 400 `invalid timezone: <value>`. `sessionId` picks an existing (or to-be-created) **root** session of `workspacePath` for the runs to use — `[A-Za-z0-9_:-]{1,200}`, no `>` sub-session path (else 400); unset / `null` / `''` = the job's own session (id = the job id). A picked session that already exists keeps its own access level, and its agent must equal `agentId` (agent lock, below); see [design/cron.md](../design/cron.md#execution-runjob) for the shared-session guards. `activeFrom` / `activeUntil` (epoch ms or `null`, recurring only): the schedule fires only inside `[activeFrom, activeUntil)`; with `runAt` → 400; `activeUntil` must be in the future and after `activeFrom` (else 400); `activeFrom` may be in the past. `workingDir` (workspace-relative or absolute; unset / `null` / `''` = the root) must be an existing directory inside `workspacePath` (else 400 `workingDir: …`); stored workspace-relative. `stateless` (boolean, else 400) runs every fire in a fresh session; with `sessionId` → 400 `stateless (new session every run) and sessionId are mutually exclusive`. Agent lock: when the effective session already exists and runs another agent → 400 `session <id> already runs agent <x>; …`; a session db that can't be read → 400 `could not read session <id> from <ws>: …`. Returns `{ok, id}` |
232
+ | PUT | `/api/cron/jobs/:id` | Partial update over the same fields. Re-validates `schedule` / `runAt` and enforces trigger-mode exclusivity on the **merged** row: both in one body → 400 `schedule and runAt are mutually exclusive`; supplying one side is a mode switch that implicitly clears the other; a body that clears the active side without supplying a replacement → 400 `schedule, runAt or webhook required`. `webhook: true` switches to webhook mode (an existing secret is kept; with `schedule` / `runAt` in the same body → 400); `webhook: false`, or a `schedule` / `runAt` alone, switches away and drops the secret. Only a webhook job may have an empty `userPrompt`: a body that empties a timed job's prompt, or switches an empty-prompt webhook job to `schedule` / `runAt` without a non-empty `userPrompt`, → 400 `userPrompt required (only a webhook job may leave it empty)`. `runAt` must be a finite future epoch-ms number or `null`. `timeoutSec` follows the same partial-body contract: omitted = unchanged, `null` = clear back to the default, a number is range-validated (60–21600) same as create. `timezone` likewise: omitted = unchanged, `null` / `''` = clear back to the host timezone, anything else is validated same as create (same two 400s). `sessionId` likewise: omitted = unchanged, `null` / `''` = back to the job's own session (an older job pinned to `cron-cron-…` then starts a fresh one), a string is validated same as create. `activeFrom` / `activeUntil` likewise (omitted = unchanged, `null` = clear); the window rules are checked on the **merged** row, so switching to `runAt` while a window is stored needs both bounds cleared in the same body, and a newly supplied `activeUntil` must be in the future (a stored past one doesn't block editing other fields). `workingDir` likewise (omitted = unchanged, `null` / `''` = back to the root); a stored folder is re-checked when `workspacePath` changes. `stateless` and the `sessionId` exclusivity are checked on the **merged** row. The agent-lock / read-error 400s (see POST) apply only when the body changes the stored agent, session, `stateless` or `workspacePath` — so an unrelated edit such as `{enabled:false}` always succeeds, even for a job whose session already runs another agent. Flips schedule/unschedule on `enabled`. Returns `{ok}` |
233
233
  | DELETE | `/api/cron/jobs/:id` | Unschedule, delete each stateless run's own session (except running / user-continued ones) and the job's `cron_runs` rows, then delete the job. A run still in progress keeps its row and session until it finalizes, then both go ([cron.md → Retention](../design/cron.md#retention)). Returns `{ok}` |
234
- | POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget; on a webhook job the prompt runs alone, no events). Returns `{ok}` |
234
+ | POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget; on a webhook job the prompt runs alone, no events — with an empty prompt the run is recorded `skipped`). Returns `{ok}` |
235
235
  | POST | `/api/cron/jobs/:id/webhook-secret` | Rotate a webhook job's secret — the old one stops working at once. 404 unknown job, 400 `not a webhook job`. Returns `{ok, webhookSecret}` |
236
236
  | POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
237
237
 
@@ -1105,6 +1105,7 @@ It does not remove session files. The full delete (rows + JSON + archive segment
1105
1105
  | `chat:queued` | `reason: 'compact'`, `message`, `sessionId` | Message queued behind a compact |
1106
1106
  | `chat:ack` | `clientMsgId` | Chat appended to the session log (or re-acked on resend) |
1107
1107
  | `listener:released` | `sessionId` | Event listener reclaimed — re-send `subscribe` (see above) |
1108
+ | `exchange:deleted` | `sessionId`, `deletedIds`, `userOrdinal`, `userRows` | An `exchange:delete` this connection sent landed: ids of every soft-deleted row of the active log, the ordinal of the turn just deleted, and the log's user-turn count (without `taskId`) that ordinal runs over (the sender's own reply only — no full `state:snapshot` follows) |
1108
1109
  | `skills:changed` | — | A skill was created / deleted / toggled, or a workspace skill folder changed on disk — re-fetch skill lists and `/api/commands`. Workspace-scoped changes reach only that workspace's sockets (see [Skills](#skills)) |
1109
1110
  | `nodes:changed` | `online: [{accountId, root \| null, shell, os, since}]` | A `halo node` connected / disconnected — full snapshot of online machines (global broadcast; `root: null` = whole machine). See [Linked machines](#linked-machines-halo-node) |
1110
1111
 
@@ -323,7 +323,7 @@ Use only for a new public release; the retained details here are not extra prepa
323
323
  5. **npm**: publish from `packages/cli/dist-pub/` **once**, using the intended dist-tag (`--tag alpha` for an alpha, not `latest`). Confirm `npm view @turmind/halo@x.y.z version` and the intended tag via `npm view @turmind/halo dist-tags.alpha` (or `dist-tags.latest` for stable); both must equal the requested version. Registry replication can lag ~2–3 minutes: wait/recheck, never republish. `dist-pub/` is release-only; desktop staging uses `dist-dev/`. Historical reason: a second v1.3.4 publish put `1.3.4-<sha>` on `latest` for ~90s.
324
324
  6. **GitHub**: `gh release create vx.y.z --notes-from-tag` (or the short CHANGELOG summary); use `--prerelease` for an alpha.
325
325
  7. **Windows exe**: `(cd packages/desktop && HALO_STAGE_FULL=1 CI=true pnpm dist:win)`, then `gh release upload vx.y.z "packages/desktop/dist/Halo Setup x.y.z.exe"`. Verify it appears in `gh release view vx.y.z --json assets`; v1.3.0 and v1.3.1 missed this asset. macOS dmg is the user's step unless requested.
326
- 8. **halo-node binaries**: nothing to build by hand. Publishing the release triggers `.github/workflows/node-go.yml`'s release job (`go vet` + `go test`, then `build.sh`), which uploads `halo-node-linux-{amd64,arm64,armv7}`, `SHA256SUMS` and `install.sh` (with the version stamped in; it isn't listed in `SHA256SUMS`). Wait for it to go green, then check that all five appear in `gh release view vx.y.z --json assets` — the admin's Linux connect command runs this tag's `install.sh`. If the job failed, rerun it with `gh workflow run node-go.yml -f tag=vx.y.z` (`workflow_dispatch`; `--clobber` makes reruns safe). The trigger needs the release created by a person (`gh release create` as yourself), because a release created with `GITHUB_TOKEN` doesn't start other workflows.
326
+ 8. **halo-node binaries**: nothing to build by hand. Publishing the release triggers `.github/workflows/node-go.yml`'s release job (`go vet` + `go test`, then `build.sh`), which uploads `halo-node-linux-{amd64,arm64,armv7}`, `halo-node-windows-amd64.exe`, `SHA256SUMS` and `install.sh` (with the version stamped in; it isn't listed in `SHA256SUMS`). Wait for it to go green, then check that all six appear in `gh release view vx.y.z --json assets` — the admin's Linux connect command runs this tag's `install.sh`. If the job failed, rerun it with `gh workflow run node-go.yml -f tag=vx.y.z` (`workflow_dispatch`; `--clobber` makes reruns safe). The trigger needs the release created by a person (`gh release create` as yourself), because a release created with `GITHUB_TOKEN` doesn't start other workflows.
327
327
 
328
328
  **npm token gotcha**: `npm publish` on this package needs a granular access token created with **"Bypass 2FA"** checked — scope / permission alone yields `403 Two-factor authentication or granular access token with bypass 2fa enabled is required`. `npm whoami` and `npm token list` succeed with a non-bypass token, so neither is a valid pre-flight; check `GET https://registry.npmjs.org/-/npm/v1/tokens` (with the token as bearer) and look for `"bypass_2fa": true` on the token in use before starting a release.
329
329
 
@@ -113,7 +113,7 @@ Top-right `Test` button:
113
113
 
114
114
  Better than the old built-in test chat — it has full workspace tools + session persistence.
115
115
 
116
- Internal agents (`internal: true`, e.g. self-evolution agents) have no Test button and never appear in the chat agent selector, the Cron agent picker or `/session new`'s default pick — they're delegated to by other agents, never driven directly. They remain editable in the management sidebar's collapsed **Internal** group.
116
+ Internal agents (`internal: true`, e.g. self-evolution agents) have no Test button and never appear in the chat agent selector, the Triggers (cron) agent picker or `/session new`'s default pick — they're delegated to by other agents, never driven directly. They remain editable in the management sidebar's collapsed **Internal** group.
117
117
 
118
118
  ## Delete
119
119