@turmind/halo 1.6.1 → 1.6.3

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><!--j6buZnQDPISEFgtAuTGyl--><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/3b4dcd0ac6b8eae8.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-74f577c1b094f942.js"/><script src="/_next/static/chunks/1b86ba38-c29611e086b2ce25.js" async=""></script><script src="/_next/static/chunks/698-19872069fed27164.js" async=""></script><script src="/_next/static/chunks/main-app-340a5b58ed229c4d.js" async=""></script><script src="/_next/static/chunks/989-ddba0eb72566da23.js" async=""></script><script src="/_next/static/chunks/app/layout-ea161287d934ba96.js" async=""></script><script src="/_next/static/chunks/1d2d5650-d8d130c85727fdbb.js" async=""></script><script src="/_next/static/chunks/147-d4200c70445aabb6.js" async=""></script><script src="/_next/static/chunks/app/page-be47983ba8ff20b1.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-74f577c1b094f942.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,[\"989\",\"static/chunks/989-ddba0eb72566da23.js\",\"177\",\"static/chunks/app/layout-ea161287d934ba96.js\"],\"ThemeProvider\"]\n3:I[24554,[\"989\",\"static/chunks/989-ddba0eb72566da23.js\",\"177\",\"static/chunks/app/layout-ea161287d934ba96.js\"],\"AppI18nProvider\"]\n4:I[63693,[],\"\"]\n5:I[74713,[],\"\"]\n6:I[5444,[],\"ClientPageRoot\"]\n7:I[41506,[\"560\",\"static/chunks/1d2d5650-d8d130c85727fdbb.js\",\"147\",\"static/chunks/147-d4200c70445aabb6.js\",\"989\",\"static/chunks/989-ddba0eb72566da23.js\",\"974\",\"static/chunks/app/page-be47983ba8ff20b1.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/3b4dcd0ac6b8eae8.css\",\"style\"]\n:HL[\"/_next/static/css/659eccb5db697b76.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"0:{\"P\":null,\"b\":\"j6buZnQDPISEFgtAuTGyl\",\"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/3b4dcd0ac6b8eae8.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><!--k5zuJrivSdbaPifBatwdr--><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/3b4dcd0ac6b8eae8.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-74f577c1b094f942.js"/><script src="/_next/static/chunks/1b86ba38-c29611e086b2ce25.js" async=""></script><script src="/_next/static/chunks/698-19872069fed27164.js" async=""></script><script src="/_next/static/chunks/main-app-340a5b58ed229c4d.js" async=""></script><script src="/_next/static/chunks/989-ddba0eb72566da23.js" async=""></script><script src="/_next/static/chunks/app/layout-ea161287d934ba96.js" async=""></script><script src="/_next/static/chunks/1d2d5650-d8d130c85727fdbb.js" async=""></script><script src="/_next/static/chunks/147-d4200c70445aabb6.js" async=""></script><script src="/_next/static/chunks/app/page-7ffcbddb49dc56c4.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-74f577c1b094f942.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,[\"989\",\"static/chunks/989-ddba0eb72566da23.js\",\"177\",\"static/chunks/app/layout-ea161287d934ba96.js\"],\"ThemeProvider\"]\n3:I[24554,[\"989\",\"static/chunks/989-ddba0eb72566da23.js\",\"177\",\"static/chunks/app/layout-ea161287d934ba96.js\"],\"AppI18nProvider\"]\n4:I[63693,[],\"\"]\n5:I[74713,[],\"\"]\n6:I[5444,[],\"ClientPageRoot\"]\n7:I[41506,[\"560\",\"static/chunks/1d2d5650-d8d130c85727fdbb.js\",\"147\",\"static/chunks/147-d4200c70445aabb6.js\",\"989\",\"static/chunks/989-ddba0eb72566da23.js\",\"974\",\"static/chunks/app/page-7ffcbddb49dc56c4.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/3b4dcd0ac6b8eae8.css\",\"style\"]\n:HL[\"/_next/static/css/659eccb5db697b76.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"0:{\"P\":null,\"b\":\"k5zuJrivSdbaPifBatwdr\",\"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/3b4dcd0ac6b8eae8.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>
@@ -4,7 +4,7 @@
4
4
  4:I[63693,[],""]
5
5
  5:I[74713,[],""]
6
6
  6:I[5444,[],"ClientPageRoot"]
7
- 7:I[41506,["560","static/chunks/1d2d5650-d8d130c85727fdbb.js","147","static/chunks/147-d4200c70445aabb6.js","989","static/chunks/989-ddba0eb72566da23.js","974","static/chunks/app/page-be47983ba8ff20b1.js"],"default"]
7
+ 7:I[41506,["560","static/chunks/1d2d5650-d8d130c85727fdbb.js","147","static/chunks/147-d4200c70445aabb6.js","989","static/chunks/989-ddba0eb72566da23.js","974","static/chunks/app/page-7ffcbddb49dc56c4.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/3b4dcd0ac6b8eae8.css","style"]
17
17
  :HL["/_next/static/css/659eccb5db697b76.css","style"]
18
- 0:{"P":null,"b":"j6buZnQDPISEFgtAuTGyl","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/3b4dcd0ac6b8eae8.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":"k5zuJrivSdbaPifBatwdr","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/3b4dcd0ac6b8eae8.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"}]]
@@ -209,6 +209,9 @@ See [guide/cli.md](../guide/cli.md).
209
209
  ### Workspace (`core/src/workspace/workspace.ts`)
210
210
  Methods: `readFile(path)` / `writeFile(path, content)` / `validatePath(path)`. `validatePath` is the traversal guard both accessors go through: segment-boundary prefix check against the resolved root (so a sibling `/x/myapp-secret` can't pass for `/x/myapp`), then a `realpathSync` re-check so a symlink inside the workspace pointing outside is rejected too (ENOENT — a not-yet-created file — falls back to the lexical check).
211
211
 
212
+ ### Image MIME table (`core/src/media/mime.ts`)
213
+ The one image ext↔MIME table: `IMAGE_EXTS` (the "photo" set that may reach a vision model or a channel's sendPhoto) / `imageMimeFromExt` / `extFromImageMime`. Server routes, channels and the CLI all import it instead of keeping their own copy (see [dev/api.md](../dev/api.md) "Inline Content-Type").
214
+
212
215
  ### GitManager (`core/src/workspace/git-manager.ts`)
213
216
  Constructed from a `Workspace` (no per-call dir argument). Read: `isRepoRoot()` (the nested-repo guard every read/write path goes through) / `getStatus()` / `getLog(count?)` / `getCommitFiles(hash)` / `getIgnoredPaths()` / `getRemotes()` / `getFileDiff(path, staged, from?, commit?)` → `{ original, modified }`. Write: `init()` / `stage(paths)` / `unstage(paths)` / `commit(msg)` / `commitAll(msg)` / `push()` / `pull()` / `addRemote(name, url)`. Backed by simple-git. See [requirements/source-control.md](../requirements/source-control.md).
214
217
 
@@ -215,7 +215,7 @@ An installed extension with manifest `settings` gets one Settings section (`sett
215
215
 
216
216
  ### Streaming transcription proxy
217
217
 
218
- WS `/api/transcribe/stream?ext=<id>&lang=<auto|xx-XX|xx-XX,yy-YY,…>` (`routes/transcribe-ws.ts`) relays an extension's live audio to **Amazon Transcribe streaming** so AWS credentials never reach the browser. `index.ts` runs both WS servers in `noServer` mode behind one http `upgrade` dispatcher (`/ws` → admin socket and `/api/transcribe/stream` → proxy, neither mounted in AgentCore mode, where every upgrade is refused; any other path → socket destroyed) — a `ws` server attached with `{ server, path }` would 400 every other path's handshake.
218
+ WS `/api/transcribe/stream?ext=<id>&lang=<auto|xx-XX|xx-XX,yy-YY,…>` (`routes/transcribe-ws.ts`) relays an extension's live audio to **Amazon Transcribe streaming** so AWS credentials never reach the browser. Every WS endpoint (`/ws` admin socket, this proxy, `/api/jupyter/channels`) is a `noServer` ws server behind one http `upgrade` listener, `ws/upgrade-router.ts` (`createUpgradeRouter`: exact-pathname lookup in a Map, plain `split('?')` instead of `new URL()` since it runs before auth on raw input; unknown path → `400`; AgentCore mode registers no routes, so every upgrade gets that 400) — a `ws` server attached with `{ server, path }` would 400 every other path's handshake. Upstream client: `@aws-sdk/client-transcribe-streaming`.
219
219
 
220
220
  - **Handshake**: login cookie (`isAuthenticated`, same as `/ws`) else 401; `ext` must be installed and declare `transcribe` else 403. The extension iframe is same-origin, so its WS carries the cookie.
221
221
  - **Upstream**: `StartStreamTranscription` (`pcm`, 16 kHz). `lang=auto` (default) → `IdentifyMultipleLanguages` + `LanguageOptions` = `ext-<id>.params.auto_languages` → manifest default → `zh-CN,zh-HK,en-US` (Cantonese must be a candidate, otherwise it is transcribed as wrong Mandarin text); one code (`^[a-z]{2}-[A-Z]{2}$`) → `LanguageCode`; a comma-separated list of 2–5 distinct codes → `IdentifyMultipleLanguages` + `LanguageOptions` = exactly that list (htrans sends `zh-CN,en-US` / `zh-HK,en-US` for its Mandarin + English / Cantonese + English options); anything else → `error bad-request`, upstream never called. "One dialect per language" in a list is left to Transcribe (its BadRequest maps to `bad-request`). Region `ext-<id>.params.region` → manifest default → `us-east-1`. Credentials: all three of `ext-<id>.secrets.access_key_id` / `secret_access_key` / `session_token` empty → the SDK default chain (`defaultProvider()`); both keys set → static keys (+ `session_token` if set); any other partial set → `error credentials` naming the missing key(s), upstream never called (no silent switch to the machine's identity). All read per connection.
@@ -231,15 +231,15 @@ The ipynb extension (2.0.0+) runs cells on **real kernels on the Halo machine**.
231
231
  - **Status** (`GET /api/jupyter/status`) never starts anything: disabled → `{enabled:false, available:false}`; executable missing → `available:false` + `error` + `hint: 'pip install jupyter_server ipykernel'`; else `<jupyter> server --version` is run once per resolved path and cached (a failing probe isn't cached, so a later `pip install` is picked up).
232
232
  - **Server**: one per Halo process, started by the first `kernelspecs` / kernel start (concurrent callers share one start), restarted lazily after it exits. Args: `server --no-browser --ServerApp.ip=127.0.0.1 --ServerApp.port=<free port> --ServerApp.port_retries=0 --ServerApp.root_dir=<filesystem root> --ServerApp.terminals_enabled=False --ServerApp.shutdown_no_activity_timeout=600 --MappingKernelManager.cull_idle_timeout=3600 --MappingKernelManager.cull_interval=300 --MappingKernelManager.cull_connected=True` (+ `allow_root` when Halo itself runs as root). The random upstream token goes in `JUPYTER_TOKEN` (env, not argv — `ps` would show it) and every upstream call sends `Authorization: token …`. Ready = `GET /api/status` answers, within 30 s, else 503 with the stderr tail. When the process exits, the kernel Map is cleared and every client socket closes 1011.
233
233
  - **Kernel start** (`POST /api/jupyter/kernels {projectId, path, name}`): `resolveProjectPath` + `validatePath` (symlinks followed; outside → 400), then the **real path of the notebook's directory** is sent as the upstream kernel `path`. jupyter_server 2.x `cwd_for_path` splits that on `/`, joins the parts onto `root_dir` and normalizes — with `root_dir` = `/` that is the absolute path itself; on Windows the native `C:\…` path is a single part and joining an absolute drive path onto the root replaces it, so any drive works (forward slashes would give a drive-relative `D:x`). jupyter silently walks up to `root_dir` when the directory doesn't exist, so Halo checks it first (400).
234
- - **Kernel ops / channels** only for ids in Halo's in-process Map (unknown → 404, also when jupyter itself started it). Upstream 404 (culled) drops the id. A kernel jupyter shuts down on its own (idle cull) does **not** close its channels sockets — the only sign is an iopub `shutdown_reply` with `content.restart: false` (a restart sends the same frame with `restart: true`, and the socket keeps working across it); the proxy watches for that frame, drops the id and closes the client sockets 1000. A kernel that dies in a cell (`exit()`, a crash) is auto-restarted by jupyter: `status: restarting` → `starting`, same id, socket stays. Channels: WS `/api/jupyter/channels?kernel=<id>` → `ws://127.0.0.1:<port>/api/kernels/<id>/channels`, frames piped both ways as-is (text and binary), frames sent before the upstream handshake are queued. No subprotocol is offered upstream or accepted from the client, so jupyter speaks the **legacy JSON protocol** (every message a text frame carrying `channel`, `header`, `parent_header`, `metadata`, `content`, `buffers` go as binary frames). The proxy pings the client every 20 s (keepalive through reverse proxies; 2 missed pongs = dead).
235
- - **Lifecycle**: a kernel with no client socket for **60 s** is DELETEd upstream (tab closed; the grace rides out a reload / reconnect, which cancels it; a kernel nobody ever connects to gets the same grace); the extension also sends a best-effort `DELETE` on unload. jupyter culls kernels idle for 1 h even while connected, and exits itself after 10 min with no kernels. Halo's graceful shutdown POSTs upstream `/api/shutdown` (stops every kernel, then exits; works on Windows too) and SIGKILLs after 5 s; a `process.on('exit')` hook kills the child on other exits. If Halo dies hard, the orphaned server goes on its own: its kernels are culled after 1 h idle, then it exits 10 min later.
234
+ - **Kernel ops / channels** only for ids in Halo's in-process Map (unknown → 404, also when jupyter itself started it). Upstream 404 (culled) drops the id. A kernel jupyter shuts down on its own (idle cull) does **not** close its channels sockets — the only sign is an iopub `shutdown_reply` with `content.restart: false` (a restart sends the same frame with `restart: true`, and the socket keeps working across it); the proxy watches for that frame, drops the id and closes the client sockets 1000. A kernel that dies in a cell (`exit()`, a crash) is auto-restarted by jupyter: `status: restarting` → `starting`, same id, socket stays. Channels: WS `/api/jupyter/channels?kernel=<id>[&session_id=<s>]` → `ws://127.0.0.1:<port>/api/kernels/<id>/channels[?session_id=<s>]` (`session_id` `^[\w-]{1,128}$`, else 400; forwarded since 1.6.3 so jupyter's per-session buffering works, see Lifecycle), frames piped both ways as-is (text and binary), frames sent before the upstream handshake are queued. No subprotocol is offered upstream or accepted from the client, so jupyter speaks the **legacy JSON protocol** (every message a text frame carrying `channel`, `header`, `parent_header`, `metadata`, `content`, `buffers` go as binary frames). The proxy pings the client every 20 s (keepalive through reverse proxies; 2 missed pongs = dead).
235
+ - **Lifecycle**: a kernel with no client socket for **60 s** is DELETEd upstream (tab closed; the grace rides out a reload / reconnect, which cancels it; a kernel nobody ever connects to gets the same grace). **Reload**: since ipynb 2.0.1 the extension sends no `DELETE` on `pagehide` (it can't tell a reload from a close; 2.0.0's unload `DELETE` killed the kernel on every reload) — it closes the socket and stores `{id, name, session, run: {msgId, cellId, index, source, count, outputs}}` in `sessionStorage` (`halo-ipynb-kernel:<projectId>:<path>`, per browser tab, claimed once by the next page of that notebook). The next page reconnects with the same id and `session_id`; a handshake that fails (id gone after the grace, server restarted) falls back to a fresh kernel on the next run. jupyter_server (verified 2.21.1) buffers a disconnected session's messages and replays them to a socket that reconnects with the same `session_id` — output printed during the reload and the cell's `execute_reply` — and the proxy only reaches that buffer by forwarding `session_id` (1.6.2 doesn't: output in the gap is lost, live output still streams, and the shell `kernel_info_request` the extension sends on reattach, queued behind the running cell, still marks it done). jupyter sends no `status` to a new socket while the kernel is busy (its connect nudge skips busy kernels), so the extension sets the badge from what it stored (no route reads a kernel's state). The extension still `DELETE`s the old kernel when the user picks another kernelspec. jupyter culls kernels idle for 1 h even while connected, and exits itself after 10 min with no kernels. Halo's graceful shutdown POSTs upstream `/api/shutdown` (stops every kernel, then exits; works on Windows too) and SIGKILLs after 5 s; a `process.on('exit')` hook kills the child on other exits. If Halo dies hard, the orphaned server goes on its own: its kernels are culled after 1 h idle, then it exits 10 min later.
236
236
  - Logs: `[Jupyter] server <version> ready on 127.0.0.1:<port>`, `kernel <id> (<name>) started in <dir>`, `no client for 60s, shutting it down`, `server exited (…); dropping N kernel(s)`.
237
237
 
238
238
  ### Extension skills
239
239
 
240
240
  An extension can ship agent skills next to its viewer: `~/.halo/global/extensions/<extId>/skills/<id>/` (`SKILL.md` + resource files + optional `config.yaml`, the ordinary skill layout). Discovery is by directory; the manifest has no skills field. `packages/server/src/agents/skill-sources.ts` is the one resolver: every skill reader goes through `listSkillDirs` / `skillSourceDir`. That covers session build / `activate_skill`, slash commands, the `/skill` verbs, `GET /skills`, Settings → Skills and evo's listing.
241
241
 
242
- - **Which folders count**: only extensions whose manifest passes the registry's own validator (`readExtensionDir`, verdict cached per manifest mtime; an extension with no `skills/` subdirs is skipped without reading its manifest). A skill id must match `^[\w-]+$` and equal `<extId>` or start with `<extId>-`; anything else is ignored with one `[Skills] extension "<extId>": ignoring skills/<id> …` warning per manifest version, not per scan. If two extensions claim one id (`foo`'s `foo-bar` vs extension `foo-bar`), the lower extension id keeps it. Manifest `platforms` is not applied: it only decides where the viewer is offered, and an extension's skills load on every platform.
242
+ - **Which folders count**: only extensions whose manifest passes the registry's own validator (`readExtensionDir`, verdict and entry path cached per manifest mtime, the entry file re-checked on every scan; an extension with no `skills/` subdirs is skipped without reading its manifest). A skill id must match `^[\w-]+$` and equal `<extId>` or start with `<extId>-`; anything else is ignored with one `[Skills] extension "<extId>": ignoring skills/<id> …` warning per manifest version, not per scan. If two extensions claim one id (`foo`'s `foo-bar` vs extension `foo-bar`), the lower extension id keeps it. Manifest `platforms` is not applied: it only decides where the viewer is offered, and an extension's skills load on every platform.
243
243
  - **Precedence**: workspace > global > extension. A higher source with the same id wholly overrides (the whole folder, no per-file fallback); the shadowed extension copy is still listed with `overridden: true`. To customise an extension skill, copy its folder into `~/.halo/global/skills/<id>/` or `<workspace>/.halo/skills/<id>/`.
244
244
  - **Scope `extension`, read-only**: `GET /skills` returns them with `scope: 'extension'` and `extId`. `POST` / `DELETE /skills` with that scope answer 403, so they never fall through to a same-id global skill. `/skill delete` refuses them, and evo neither lists nor patches them. The admin shows them in a Skills → **Extensions** section, rendered as a read-only file view with no editor, create or delete, and the subtitle "from extension X".
245
245
  - **Not auto-enabled**: an agent uses one only when its `skills:` lists the id (ticked in the agent form like any skill). An id whose extension is gone shows the usual "not installed" hint.
@@ -29,7 +29,7 @@ Schedules are **durable** — the source of truth is the `cron_jobs` table; in-m
29
29
 
30
30
  - **Recurring**: standard cron expression (`job.schedule`), optional `timezone`, optional **active window** `[activeFrom, activeUntil)` (epoch ms, `active_from` / `active_until`, NULL = unbounded; `CRON_MIGRATIONS` slot 2). The window is passed to croner as native `startAt` / `stopAt` (`activeWindowOptions` in `runner.ts`; `startAt` is `activeFrom − 1 ms` because croner's startAt is exclusive), so fires outside it simply never happen — no `cron_runs` row. "Pause until X" = push `activeFrom` to X; there is no skip-list for a gap in the middle. Past `activeUntil` the job stays `enabled` (so it can be extended later) but `scheduleJob` creates no croner and logs `past activeUntil; not scheduled` once — the id goes into `_expired` so reconcile's "fingerprint cached but not in `_active`" retry branch treats it as handled instead of re-scheduling (and re-logging) every 10s. The REST `nextRunAt` uses the same options, so the list shows the first post-pause fire; the admin badges a job "active from …" / "ended". Run-now ignores the window. Recurring only: a window together with `runAt` is rejected at every write surface (REST on the merged row, skill helper).
31
31
  - **One-shot**: `job.runAt` (epoch ms) — croner fires once at that instant. After it completes, `finalize` sets `enabled=0` so it never re-fires. A `runAt` already in the past at schedule time is marked `lastRunStatus='missed'` and disabled (rather than firing immediately or retrying every reconcile). The `run_at` column is `CRON_MIGRATIONS` slot 0 in `db/cron-db.ts`, run through the shared `runMigrations` / `PRAGMA user_version` mechanism (see [storage.md](storage.md#schema-change-rules)).
32
- - **Folder + stateless** (`CRON_MIGRATIONS` slot 4, v5): `cron_jobs.working_dir` — the folder the run focuses on, passed as `halo cli --dir` (same semantics as a sub-agent's `working_dir`: the folder's `.halo/INSTRUCTIONS.md` chain joins the prompt, plus a `Working directory:` line); stored workspace-relative, NULL = the workspace root; validated at every write surface (inside the workspace, existing — REST re-checks a stored folder when `workspacePath` moves). `cron_jobs.stateless` (0/1) — **new session every run**: each fire gets a fresh root session `cron-<id>-<base36 ms>` (`perRunSessionId`, same no-double-prefix rule as `effectiveSessionId`), so nothing accumulates across fires; mutually exclusive with `session_id` (`400` on the merged row). `cron_runs.session_id` — the session that run used, the only record of a stateless run's id (NULL on rows from before v5).
32
+ - **Folder + stateless** (`CRON_MIGRATIONS` slot 4, v5): `cron_jobs.working_dir` — the folder the run focuses on, passed as `halo cli --dir` (same semantics as a sub-agent's `working_dir`: the folder's `.halo/INSTRUCTIONS.md` chain joins the prompt, plus a `Working directory:` line); stored workspace-relative, NULL = the workspace root; validated at every write surface (inside the workspace, existing — REST re-checks a stored folder when `workspacePath` moves). `cron_jobs.stateless` (0/1) — **new session every run**: each fire gets a fresh root session `cron-<id>-<base36 ms>` (`perRunSessionId`, same no-double-prefix rule as `effectiveSessionId`), so nothing accumulates across fires (only the newest 10 runs are kept; per-run sessions are deleted along with their run rows, see [Retention](#retention)); mutually exclusive with `session_id` (`400` on the merged row). `cron_runs.session_id` — the session that run used, the only record of a stateless run's id (NULL on rows from before v5).
33
33
 
34
34
  ### Trigger-mode exclusivity (the db contract)
35
35
 
@@ -40,6 +40,8 @@ Exactly **one** of `schedule` / `runAt` is set per job. `runner.ts` `scheduleJob
40
40
  - Clearing the active side with nothing to replace it (`schedule: ''` on a recurring job, `runAt: null` on an at-mode job) → `400 schedule or runAt required`. A triggerless job would sit `enabled` and never fire.
41
41
  - A non-number, non-null `runAt` is rejected (`400`) *before* the exclusivity check — otherwise garbage types slip past `typeof body.runAt === 'number'` and land in the db as-is.
42
42
 
43
+ The skill helper `manage-cron.py update` follows the same rules (`--schedule` / `--run-at` switch the mode, both or neither left is refused); `test/manage-cron-py.test.ts` pins its parity with REST.
44
+
43
45
  ### Hot-reload + out-of-band edits
44
46
 
45
47
  REST route mutations call `scheduleJob` / `unscheduleJob` directly. But the `cron` skill (and manual ops) edit `cron.db` over a *different* sqlite connection. `reconcileFromDb` (10s timer) catches those:
@@ -118,7 +120,7 @@ interface CronChannelDispatcher {
118
120
 
119
121
  ## Retention
120
122
 
121
- - **Runs**: keep newest `RUNS_PER_JOB_KEEP` (100) rows per job; older pruned on each new run via a single `DELETE … NOT IN (SELECT … LIMIT 100)` over the `(job_id, started_at)` index.
123
+ - **Runs**: keep newest `RUNS_PER_JOB_KEEP` (100) rows per job, `STATELESS_RUNS_PER_JOB_KEEP` (10) for a stateless job; older pruned on each new run (`pruneOldRuns`). A stateless run's session follows its row: a run whose `session_id` is its own `perRunSessionId` has that session deleted first (through the live session manager when the workspace is open, else its db row + files directly), skipping a session that is still running or that the user continued (`updated_at` after the run's `completed_at`). A stateful job's session is never deleted. Deleting a job (`deleteJobRuns`) applies the same rule to all its runs.
122
124
  - **Logs**: files older than 30 days deleted on a daily timer (db row kept; UI shows "log unavailable"). `cron_runs` rows older than 60 days are also dropped.
123
125
 
124
126
  ## Key files
@@ -128,7 +130,7 @@ interface CronChannelDispatcher {
128
130
  - `packages/server/src/db/cron-db.ts` — `cron_jobs` + `cron_runs` schema, `CRON_MIGRATIONS`, `getCronDb`.
129
131
  - `packages/server/src/agents/session-manager.ts` — `forgetExternalWrite` (drop the stale in-memory copy of a session a cli child just wrote).
130
132
  - `packages/server/src/channels/<ch>/cron-dispatcher.ts` — per-channel `dispatch` + `listTargets`.
131
- - `packages/server/templates/skills/cron/SKILL.md` — the agent-facing skill that CRUDs `cron_jobs` (edits the db directly; `reconcileFromDb` picks it up).
133
+ - `packages/server/templates/skills/cron/SKILL.md` — the agent-facing skill that CRUDs `cron_jobs` (edits the db directly; `reconcileFromDb` picks it up). It receives `{{channel.type/account_id/chat_id}}` placeholders, so a job created from a chat defaults its target to that chat, and "delete my cron" inside a chat reverse-looks-up its subscriptions with `list --chat-id <id>`.
132
134
 
133
135
  ## Scope
134
136
 
@@ -222,7 +222,7 @@ type ContentBlockEntry =
222
222
 
223
223
  `turnId` uniquely identifies each LLM API call. Content blocks and the corresponding usage message in the same turn share the same turnId.
224
224
 
225
- The `usage` event rotates `turnId` on both root and sub-session turn state without flushing the assistant message: the agent loop yields `tool_call`s before `usage` and runs the tools after, so at usage time every tool call of that model call is still pending — a flush there would split thinking/text from the tool_calls into two assistant messages (the pre-fix sub-session behavior). One assistant message therefore spans all model calls of a turn, each call's blocks tagged with its own turnId; it reaches disk at `complete` / `agent_done`, the next `user` or `system` event, or the interrupt flush. Root and sub-session logs persist by one rule: `persistLog` overwrites the session's own file whole from its UI log, and every save carries the in-flight turn as a temporary assistant message (`createSaveSnapshot`). A sub-session log starts from its own file (`loadSubHistory`), so it always holds the full history; writes are debounced 500ms per session id (tool_call / tool_result / usage / context / user / system / error) and flushed at turn end (root `complete`, sub `agent_done`) and on interrupt.
225
+ The `usage` event rotates `turnId` on both root and sub-session turn state without flushing the assistant message: the agent loop yields `tool_call`s before `usage` and runs the tools after, so at usage time every tool call of that model call is still pending — a flush there would split thinking/text from the tool_calls into two assistant messages (the pre-fix sub-session behavior). One assistant message therefore spans all model calls of a turn, each call's blocks tagged with its own turnId; it reaches disk at `complete` / `agent_done`, the next `user`, `system` or `error` event, or the interrupt flush. Those three run `flushCompletedAssistantMessage` before logging their row: the content up to the first pending tool call is flushed above the row, and the pending tail stays in the buffer below it. `error` always lands in the root log, a sub-session's error included, so it splits the root's buffer (the admin adds the error frame with no taskId, and `placeAroundStreaming` splits the root bubble the same way). Root and sub-session logs persist by one rule: `persistLog` overwrites the session's own file whole from its UI log, and every save carries the in-flight turn as a temporary assistant message (`createSaveSnapshot`). A sub-session log starts from its own file (`loadSubHistory`), so it always holds the full history; writes are debounced 500ms per session id (tool_call / tool_result / usage / context / user / system / error) and flushed at turn end (root `complete`, sub `agent_done`) and on interrupt.
226
226
 
227
227
  ### Render rules
228
228
 
@@ -231,6 +231,8 @@ The `usage` event rotates `turnId` on both root and sub-session turn state witho
231
231
  | Normal | user, assistant, notification | tool_call, tool_result, usage, context, agent_start, agent_done |
232
232
  | Debug | user, assistant, notification, context, agent_start, agent_done + inlined usage | tool_call, tool_result (already inlined in the assistant's contentBlocks) |
233
233
 
234
+ Debug usage badges are grouped by turnId, not by log position: `buildExchanges` (`message-list.tsx`) puts each usage row in the exchange holding the last assistant message with a block of that turnId, and the badge renders after that turnId's last block. A usage row reaches the log when its model call returns, before the call's tools run, so a user row that lands mid-call is logged after the badge while the pending tool_calls are flushed below it. Grouping by position left the badge on the bubble above the user row. A usage row with no matching block falls back to the last assistant message of its own exchange.
235
+
234
236
  Assistant rendering priority:
235
237
  1. Has `contentBlocks` → render in contentBlocks order
236
238
  2. No contentBlocks, has `toolCalls` → tools first, then content (legacy)
@@ -526,8 +528,8 @@ Existing agent yaml values always win over these defaults — switching provider
526
528
  - `image` / `video` / `audio` (boolean) → modality badges in the form; at runtime, unsupported modality inputs are filtered out with a text notice instead of causing API errors
527
529
  - `promptCaching.ttlPresets` → Prompt Caching dropdown
528
530
  - `thinking.mode`:
529
- - `adaptive` → wire format `thinking: {type: 'adaptive'}` + `output_config.effort` (Sonnet 4.6+ / Opus 4.6 and later, incl. Opus 5)
530
- - `manual` → wire format `thinking: {type: 'enabled', budget_tokens: N}` (Haiku 4.5 and other legacy thinking models). UI offers a budget number input; runtime translates effort labels via the table in `bedrock-agent.ts` if needed.
531
+ - `adaptive` → wire format `thinking: {type: 'adaptive'}` + `output_config.effort` (Sonnet 4.6+ / Opus 4.6 and later, incl. Opus 5). Qwen 3.8 Max / Flash use the same `output_config.effort` with `thinking: {type: 'enabled'}`, unless the agent.yaml carries an explicit `budget_tokens`.
532
+ - `manual` → wire format `thinking: {type: 'enabled', budget_tokens: N}` (Haiku 4.5 and other legacy thinking models). UI offers a budget number input; runtime translates effort labels via `effortToBudget` in `anthropic-request.ts` if needed.
531
533
  - `thinking.effortPresets` → Thinking effort dropdown (when `mode: adaptive`)
532
534
  - Omit a block → the UI hides that control for this model
533
535
 
@@ -92,10 +92,10 @@ Source: [event-processor.ts](../../../packages/server/src/ws/event-processor.ts)
92
92
 
93
93
  | Agent event | WS type | Fields |
94
94
  |---|---|---|
95
- | `thinking_delta` | `chat:thinking` | text, agentName, taskId, turnId, sessionId |
96
- | `stream_delta` | `chat:stream` | text, agentName, taskId, turnId, sessionId |
97
- | `thinking` | `chat:thinking` | text, agentName, taskId, turnId, sessionId — **not forwarded** when `streamed: true` |
98
- | `stream` | `chat:stream` | text, agentName, taskId, turnId, sessionId — **not forwarded** when `streamed: true` |
95
+ | `thinking_delta` (no taskId) | `chat:thinking` | text, agentName, turnId, sessionId |
96
+ | `stream_delta` (no taskId) | `chat:stream` | text, agentName, turnId, sessionId |
97
+ | `thinking` (no taskId) | `chat:thinking` | text, agentName, turnId, sessionId — **not forwarded** when `streamed: true` |
98
+ | `stream` (no taskId) | `chat:stream` | text, agentName, turnId, sessionId — **not forwarded** when `streamed: true` |
99
99
  | `agent_start` | `agent:start` | agentName, task, taskId, sessionId |
100
100
  | `agent_done` | `agent:done` | agentName, taskId, sessionId |
101
101
  | `tool_call` | `agent:tool_call` | tool, toolUseId, input, agentName, taskId, turnId, sessionId |
@@ -110,6 +110,8 @@ Source: [event-processor.ts](../../../packages/server/src/ws/event-processor.ts)
110
110
 
111
111
  Every provider streams (Bedrock, the Anthropic-Messages providers anthropic / mimo / minimax / qwen, the OpenAI-family openai / deepseek / kimi / zhipu / doubao / hunyuan, and Mantle aws-bedrock-mantle / aws-bedrock-openai): they emit `stream_delta` / `thinking_delta` per chunk *during* the model call, and the whole `stream` / `thinking` that follows the call is stamped `streamed: true` — the wire frame is the same `chat:stream` / `chat:thinking` either way (the admin appends by `turnId`), so the whole event is dropped to avoid rendering the text twice. A provider that never calls `onDelta` would still send the whole event only, exactly as before streaming existed.
112
112
 
113
+ Sub-agent text is root-tree noise on this wire: the listener is the root's (sub ids normalize to it), every admin view filters `taskId` rows out, and the Sessions tab reads a sub-session's log from disk. So the four text events above go out **root only**. A sub-agent's `stream*` / `thinking*` produce no frame, while its `agent:start` / `agent:done`, tool frames, `agent:context`, `chat:system` and `error` still carry `taskId`. Web SSE (and ACP on top of it), the IM channels and A2A register their own event listeners rather than reading these frames, so they are unaffected. Two harmless side effects in the admin. A text-only sub-agent leaves its empty `agent:start` placeholder in the root store, and after a link drop `convergeStaleStreaming` may mark it interrupted; every view filters that row out anyway. A background tab's unread dot no longer lights for sub-agent text alone, though the sub-agent's tool and `agent:*` frames still light it.
114
+
113
115
  `sessionId` is the session the emitting listener belongs to. A connection holds one listener per subscribed session (`createEventListener(client, sessionId)`), and each listener is bound to its id when it is created. Since 1.5.3-alpha the session-scoped frames sent outside the event mapping carry it too: `compact:started` / `compact:summarizing` / `compact:done`, `session:compacted`, `chat:queued`, `chat:system` / `error` replies to a command or chat, the detached-buffer replays (`agent:start` / `agent:done` / `error` / `chat:followup` from `bufferDetachedNotification`), and `chat:usage` on subscribe.
114
116
 
115
117
  **Admin routing** (`storeForFrame` in `chat-tabs.ts`, used by `chat-handlers.ts` / `agent-handlers.ts` / `state-handlers.ts`): a frame with a `sessionId` goes to the store of the loaded tab holding that session, background tabs included. A frame landing in a background tab lights its unread dot; state flips (snapshot, compact progress) don't. A frame for a session no tab has loaded (never opened, or released by a reconnect) is dropped. A frame without a `sessionId` (connection-level replies) goes to the tab on screen. `<<<CAPTURE>>>` / `<<<SHOW>>>` markers fire on `chat:complete` only for the tab on screen — a background session must not grab a screen frame or drive the face the user is looking at.
@@ -141,6 +143,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
141
143
  | `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}`. See [cron.md](cron.md). |
142
144
  | `evolution:run_changed` / `evolution:apply_changed` | `evolution/ticker.ts` + `routes/evolution.ts` (broadcast) | Evolution run/apply state changed — drives the Evolution tab. See [evolution.md](evolution.md). |
143
145
  | `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). |
146
+ | `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. |
144
147
  | `extension:changed` | `extensions/watcher.ts` `rescanAndBroadcast` (broadcast to all clients) | `~/.halo/global/extensions/` changed (install / upgrade / uninstall, from the admin upload, the `extension` skill, or a manual `mv`/`rm`) — carries the **full** `ExtensionsSnapshot` (`{extensions, errors}`), not a diff, and only fires when the snapshot key differs from the last one. The admin replaces its extension list wholesale: the preview registry re-resolves open tabs (an upgraded extension remounts, or shows a banner if the tab is dirty; an uninstalled one falls back to the "no built-in preview" page) and the Settings → Extensions panel re-renders. Global, no workspace marker — extensions are server-wide. See [canvas-extensions.md](canvas-extensions.md). |
145
148
 
146
149
  ### `error` frames: optional `code`
@@ -24,9 +24,9 @@ Runtime names (`runtime:` value → class): `bedrock-invoke` → `BedrockAgent`,
24
24
  |---|---|---|---|---|
25
25
  | `aws-bedrock-claude-invoke` | `bedrock-invoke` | `BedrockAgent` | `bedrock-agent.ts` | Anthropic Messages API via AWS SDK |
26
26
  | `kimi` | `kimi` | `KimiAgent` | `kimi-agent.ts` | OpenAI-compatible, streaming via `fetchChatCompletionStream` (usage arrives in its own `choices: []` chunk), thinking (reasoning_content). Cached tokens via `cachedPromptTokens` (every known usage key — which one Kimi reports isn't pinned). Two API generations, branched on modelId: **K3** — vision (base64 data URLs only, no public-URL input), thinking always-on server-side, sole knob is top-level `reasoning_effort` (enum currently just `"max"`); **K2.6** — no vision, no `reasoning_effort` field at all, only the `thinking:{type:'enabled'|'disabled'}` toggle. Never send the K2.x `thinking` parameter to K3 |
27
- | `deepseek` | `deepseek` | `DeepSeekAgent` | `deepseek-agent.ts` | OpenAI-compatible, streaming via `fetchChatCompletionStream` (first chunk has `content: null` + `reasoning_content: ''`; the usage chunk still carries `choices`), thinking (reasoning_content). Vision per model: `deepseek-flash` (V4.1-Flash) sees `image_url` data URLs; `deepseek-v4-pro` accepts the block but ignores it (live-probed 2026-09-24 with a 64×64 blue PNG) — registry `image` flag gates it |
27
+ | `deepseek` | `deepseek` | `DeepSeekAgent` | `deepseek-agent.ts` | OpenAI-compatible, streaming via `fetchChatCompletionStream` (first chunk has `content: null` + `reasoning_content: ''`; the usage chunk still carries `choices`), thinking (reasoning_content). Thinking is on by default, so off is sent explicitly as `thinking:{type:'disabled'}`. On, it sends `reasoning_effort` with the label unmapped: presets are the API's own low / high / max, and the API folds medium / xhigh to high itself, so older agents keep working. Vision per model: `deepseek-flash` (V4.1-Flash) sees `image_url` data URLs; `deepseek-v4-pro` accepts the block but ignores it (live-probed 2026-09-24 with a 64×64 blue PNG) — registry `image` flag gates it |
28
28
  | `minimax` | `minimax` | `MiniMaxAgent` | `minimax-agent.ts` | Anthropic Messages API (x-api-key), streaming via `fetchAnthropicStream` — note `message_start.usage` is all zeros and the real input / cache counts arrive in `message_delta.usage` (the accumulator takes them from there). Two thinking schemes, branched on registry `thinking.mode`: **M3** (default) — vision, thinking off by default and enabled via `thinking:{type:'adaptive'}` (no levels, no budget_tokens), no `promptCaching` in the manifest on purpose (official docs scope `cache_control` to M2.x only; M3 has passive automatic caching); **M2.7** — no vision, thinking always-on (manual budget_tokens), prompt caching (5m, M2.7 doesn't report cache_creation_input_tokens) |
29
- | `qwen` | `qwen` | `QwenAgent` | `qwen-agent.ts` | Aliyun Bailian Anthropic-compatible API, streaming via `fetchAnthropicStream` (SSE frames come as `data:{…}` with no space after the colon — the reader accepts both), thinking enable/disable both honored, prompt caching (5m only), vision on Plus, max no vision |
29
+ | `qwen` | `qwen` | `QwenAgent` | `qwen-agent.ts` | Aliyun Bailian Anthropic-compatible API, streaming via `fetchAnthropicStream` (SSE frames come as `data:{…}` with no space after the colon — the reader accepts both), thinking enable/disable both honored; omitting the field means thinking on, so off is always sent as `type:'disabled'`. Two thinking schemes, branched on registry `thinking.mode`: **3.8 Max / Flash** (`adaptive`) send `thinking:{type:'enabled'}` + `output_config.effort` (low / medium / xhigh; the API folds high / max onto xhigh). **3.7 / 3.6** (`manual`) send `budget_tokens` via `effortToBudget`. An agent.yaml with an explicit `budget_tokens` keeps sending it on either. Prompt caching (5m only), vision on Plus, max no vision |
30
30
  | `hunyuan` | `hunyuan` | `HunyuanAgent` | `hunyuan-agent.ts` | Tencent Hy3 / Hy4 preview / Hy3 preview via OpenAI-compatible (tokenhub.tencentmaas.com), streaming via `fetchChatCompletionStream` (`stream_options.include_usage` accepted; usage in its own `choices: []` chunk), reasoning_effort low/high, thinking off sends `reasoning_effort: 'none'` (an omitted field thinks by default — live-probed 2026-10-10), automatic prompt caching (cached_tokens), no vision |
31
31
  | `doubao` | `doubao` | `DoubaoAgent` | `doubao-agent.ts` | ByteDance Doubao Seed 2.1 Pro + Seed-2.0 (pro/lite/mini/code) on Volcengine Ark, OpenAI-compatible, streaming via `fetchChatCompletionStream` (usage in its own `choices: []` chunk) + `thinking:{type:'enabled'/'disabled'}` (no `auto`), automatic prompt caching. Images go out as `image_url` content parts; only 2.1 Pro is `image: true` (the 2.0 models misread a red PNG), so the registry flag gates the rest |
32
32
  | `zhipu` | `zhipu` | `ZhipuAgent` | `zhipu-agent.ts` | Zhipu GLM-5.x on open.bigmodel.cn, OpenAI-compatible, streaming via `fetchChatCompletionStream` (tool-call arguments arrive complete in one fragment; usage chunk carries `choices`). Output cap via `max_tokens` only (`max_completion_tokens` is silently ignored). Thinking branched on modelId: **glm-5.3\*** always thinks — always sends `thinking:{type:'enabled',clear_thinking:false}`, `reasoning_effort` only when low/high/max (`disabled` / other efforts 400); other ids (glm-5.2) honour `thinking:{type:'disabled'}`. Vision per model: `glm-5.3-flash` / `glm-5.3-flashx` see `image_url` data URLs; `glm-5.3` / `glm-5.2` are text-only and 400 on any image part (code 1210, classified `multimodal_4xx` so leftover history images degrade). Automatic caching, `prompt_tokens_details.cached_tokens` (included in `prompt_tokens`, subtracted). |
@@ -100,7 +100,7 @@ Installed extensions are directories under `~/.halo/global/extensions/<id>/` wit
100
100
  | 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 }` |
101
101
  | POST | `/api/jupyter/kernels/:id/interrupt` · `…/restart` | cookie | `{ ok: true }`; 404 unless `:id` is a kernel Halo started (and jupyter still has) |
102
102
  | DELETE | `/api/jupyter/kernels/:id` | cookie | Shut the kernel down → `{ ok: true }`; 404 for an unknown id |
103
- | WS | `/api/jupyter/channels?kernel=<id>` | cookie | Kernel channels: frames piped as-is both ways to the upstream `/api/kernels/<id>/channels` — legacy JSON text protocol (`channel` field), no subprotocol. Handshake 401 without the cookie, 404 for an unknown kernel. The last socket closing starts a 60 s grace, then the kernel is shut down — see [design](../design/canvas-extensions.md#notebook-kernels-jupyter-proxy) |
103
+ | WS | `/api/jupyter/channels?kernel=<id>[&session_id=<s>]` | cookie | Kernel channels: frames piped as-is both ways to the upstream `/api/kernels/<id>/channels` — legacy JSON text protocol (`channel` field), no subprotocol. `session_id` (`^[\w-]{1,128}$`, 1.6.3+) is forwarded, so a reconnect with the same one gets what jupyter buffered while the session had no socket. Handshake 401 without the cookie, 404 for an unknown kernel, 400 for a malformed `session_id`. The last socket closing starts a 60 s grace, then the kernel is shut down — see [design](../design/canvas-extensions.md#notebook-kernels-jupyter-proxy) |
104
104
 
105
105
  Extension capabilities the host understands (`ExtensionCapability`): `save`, `media`, `transcribe`, `fs-read`; an unknown value makes the manifest an error. `fs-read` adds no extension-specific route: the host serves the extension's scoped `fs` frames and `pick` file picker from the [Filesystem browse](#filesystem-browse-not-project-scoped--for-the-workspace-picker) routes above (`scope: 'system'`) and the ordinary `/api/files/*` routes (`scope: 'workspace'`) — see [design/canvas-extensions.md](../design/canvas-extensions.md#scoped-file-access-and-the-picker-fs-read).
106
106
 
@@ -217,7 +217,7 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
217
217
  | 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 cli actually runs in: `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) |
218
218
  | POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt, schedule, runAt?, timezone?, timeoutSec?, sessionId?, workingDir?, stateless?, targets?, enabled?}`. Exactly one of `schedule` (5-field cron) or `runAt` (epoch ms, future) is required; mutually exclusive. `timeoutSec` must be an integer 60–21600 (else 400); unset = runner default 3600. `sessionId` picks an existing (or to-be-created) **root** session of `workspacePath` for the cli to run in — `[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}` |
219
219
  | 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 or runAt required`. `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. `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}` |
220
- | DELETE | `/api/cron/jobs/:id` | Unschedule, delete every `cron_runs` row for the job, then delete the job. Returns `{ok}` |
220
+ | DELETE | `/api/cron/jobs/:id` | Unschedule, delete each stateless run's own session (except running / user-continued ones) and every `cron_runs` row for the job, then delete the job. Returns `{ok}` |
221
221
  | POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget). Returns `{ok}` |
222
222
  | POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
223
223
 
@@ -434,6 +434,8 @@ File: `packages/server/src/routes/skills.ts`
434
434
  | DELETE | `/api/skills/:id?scope=&projectId=` | Delete skill directory + settings entry. `scope=extension` → 403 (read-only) |
435
435
  | PATCH | `/api/skills/:id/toggle?scope=&projectId=` | Toggle disabled in workspace DB. `projectId` required; `scope` is `global` / `workspace` / `extension` (an extension skill's disable key is `extension:<id>` — a workspace setting, nothing written into the extension's folder). Returns `{ ok, disabled }`. |
436
436
 
437
+ A successful create / delete / toggle pushes the WS frame `skills:changed` (no payload) — to sockets bound to `projectId` for a workspace skill or a toggle, to every socket for a global skill; a refused call (403 / 404 / 409) pushes nothing. `/skill disable|enable|delete` in any chat or channel does the same, and the workspace watcher sends it when a `.halo/skills/` folder's ids or SKILL.md frontmatter change on disk. See [design/ws.md](../design/ws.md#other-server--client-messages).
438
+
437
439
  ## Commands
438
440
 
439
441
  File: `packages/server/src/routes/commands.ts`
@@ -981,8 +983,8 @@ It does not remove session files. The full delete (rows + JSON + archive segment
981
983
 
982
984
  | Type | Fields | Purpose |
983
985
  |---|---|---|
984
- | `chat:stream` | `text`, `agentName`, `turnId`, `taskId?` | Text chunk from the agent |
985
- | `chat:thinking` | `text`, `agentName`, `turnId`, `taskId?` | Thinking chunk |
986
+ | `chat:stream` | `text`, `agentName`, `turnId` | Text chunk from the root agent (a sub-agent's text isn't sent on this wire — see [design/ws.md](../design/ws.md#agent-event--ws-message-mapping)) |
987
+ | `chat:thinking` | `text`, `agentName`, `turnId` | Thinking chunk (root only, as above) |
986
988
  | `agent:tool_call` | `tool`, `toolUseId`, `input`, `agentName`, `turnId`, `taskId?` | Tool invocation card |
987
989
  | `agent:tool_result` | `result`, `toolUseId`, `agentName`, `durationMs`, `taskId?` | Tool result |
988
990
  | `chat:usage` | `contextTokens`, `outputTokens`, `usage`, `modelId`, `turnId` | Token accounting per model call (root only) |
@@ -991,5 +993,6 @@ It does not remove session files. The full delete (rows + JSON + archive segment
991
993
  | `chat:queued` | `reason: 'compact'`, `message`, `sessionId` | Message queued behind a compact |
992
994
  | `chat:ack` | `clientMsgId` | Chat appended to the session log (or re-acked on resend) |
993
995
  | `listener:released` | `sessionId` | Event listener reclaimed — re-send `subscribe` (see above) |
996
+ | `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)) |
994
997
 
995
998
  Events with `taskId` set belong to sub-agent turns (nested sessions); events without are the root agent's. Session-scoped frames carry `sessionId` (the root session they belong to); the admin routes each one to the tab holding that session, and a frame without one goes to the tab on screen. Full list in [design/ws.md](../design/ws.md).
@@ -508,6 +508,28 @@ Still good practice: build server + admin **before** `pnpm dist:arm64`.
508
508
  `find resources/{server,cli}-runtime -type f | awk '{print length($0)}' |
509
509
  sort -rn | head -1` — keep the longest relative path well under ~200.
510
510
 
511
+ - **The same "Halo cannot be closed" prompt, second cause: an orphaned bundled
512
+ `node.exe` the installer can't see.** `installer.nsh customCheckAppRunning`
513
+ runs `taskkill /im Halo.exe /T /F` and then kills any `node.exe` whose image
514
+ lives under `$INSTDIR` — the second pass exists for orphans whose Halo.exe
515
+ parent already died (crash, Task Manager, an interrupted `halo.cmd` child),
516
+ which the tree walk can't reach. That pass used `Get-Process node | Where
517
+ Path -like '$INSTDIR\*'` and **never matched anything**: the NSIS installer
518
+ is a 32-bit process, so the `powershell` it spawns is the 32-bit SysWOW64
519
+ one, and there `Get-Process` returns an empty `.Path` for 64-bit processes.
520
+ The orphan kept its image lock on `resources\node.exe`, extraction failed
521
+ five times, and the user got the Retry prompt with no Halo window left to
522
+ close. Fixed by matching `Get-CimInstance Win32_Process` `.ExecutablePath`,
523
+ which is readable from either bitness. Rule: **any PowerShell the installer
524
+ runs is 32-bit** — never filter processes by `Get-Process .Path` /
525
+ `.MainModule` there. To tell the two causes apart on a user's box, list
526
+ what still runs from the install dir (64-bit PowerShell): `Get-CimInstance
527
+ Win32_Process | ? ExecutablePath -like "$env:LOCALAPPDATA\Programs\Halo\*"`
528
+ — non-empty = this cause (append `| % { Stop-Process -Id $_.ProcessId
529
+ -Force }` as the workaround, then Retry); empty = look at the MAX_PATH entry
530
+ above. The fix ships in the new installer, and its check runs before the old
531
+ version's uninstaller, so upgrades from any older build benefit.
532
+
511
533
  ## Verifying a build
512
534
 
513
535
  ```bash
@@ -23,7 +23,7 @@ The primary surface for talking to an agent.
23
23
  - Markdown + code-block highlighting
24
24
  - Tool-call card: expandable, shows tool name / input / output
25
25
  - Sub-agent streams stay out of the chat: their reports land as a green callout, and Debug mode adds agent start/done markers labelled with the agent name (e.g. "Coder", "Researcher")
26
- - A reply that has produced nothing yet shows a spinner with "Thinking..."; once the model streams reasoning text, the spinner gives way to a **live thinking panel**: expanded, muted, with a "Thinking..." header and char count, capped at about 9 lines and tailing its own text (so the chat log grows by at most the cap). It folds to the one-line collapsed "Thinking · N chars" header (click to expand; it stays in the log) as soon as text or a tool call follows, or the message settles. Providers that stream no reasoning text stay on the spinner; Debug mode is unchanged (spinner until text or a tool call arrives, collapsed headers only)
26
+ - A reply that has produced nothing yet shows a spinner with "Thinking..."; once the model streams reasoning text, the spinner gives way to a **live thinking panel**: expanded, muted, with a "Thinking..." header and char count, capped at about 9 lines and tailing its own text (so the chat log grows by at most the cap). It folds to the one-line collapsed "Thinking · N chars" header (click to expand; it stays in the log) as soon as text or a tool call follows, or the message settles. Providers that stream no reasoning text stay on the spinner. Debug mode shows the same live panel; it only adds to the normal view
27
27
  - **Follow the bottom by intent**: while a reply streams the log follows its end, and scrolling up stops the follow. Upward input — mouse wheel, touch drag, PageUp / ArrowUp / Home / Shift+Space, or grabbing the scrollbar — detaches the moment it arrives, so a fast stream can't snap the view back mid-gesture; upward input that a nested scroller (the live thinking panel, an expanded tool body) would take doesn't count. The log re-attaches only when the view is scrolled back down to within 80 px of the end. Applies to the chat panel, the Sessions tab viewer and the live thinking panel alike; a reader scrolling inside the thinking panel stops its tail the same way
28
28
  - The user bubble shows its send time (`HH:mm`, browser-local) at the left of its header row, after the expand chevron (see [User-message actions](#user-message-actions-expand--copy--delete)); hovering it gives the full date. Same bubble component as the Sessions tab, so both surfaces get it
29
29
  - In Debug mode the system-prompt `context` row stays in the list but doesn't open an exchange of its own, so there is no blank strip above the first bubble
@@ -86,7 +86,7 @@ See [requirements/command.md](command.md) for the full command surface.
86
86
  ### Graceful interrupt
87
87
  Sending a new message while the agent is generating **does not** abort — the message goes to the server queue, the agent finishes the current turn at the next safe checkpoint (after a tool call), and then runs the queued message. Queueing multiple messages is supported; they run in order.
88
88
 
89
- **Live placement.** When a main user message or notification lands while the main bubble is still streaming, the live view lays it out the way the server persists it, so it matches what a reload shows. Before logging such a row the server runs `flushCompletedAssistantMessage`: the streaming content up to its first pending tool call settles **above** the row, and the rest (or a fresh empty streaming slot) continues streaming **below** it (`placeAroundStreaming` in `chat-store.ts`).
89
+ **Live placement.** When a main user message or notification (an error row included) lands while the main bubble is still streaming, the live view lays it out the way the server persists it, so it matches what a reload shows. Before logging such a row the server runs `flushCompletedAssistantMessage`: the streaming content up to its first pending tool call settles **above** the row, and the rest (or a fresh empty streaming slot) continues streaming **below** it (`placeAroundStreaming` in `chat-store.ts`).
90
90
 
91
91
  ### Stop
92
92
  The Stop button hard-aborts — the server receives `chat:stop`, AbortController fires, queue clears, buffers flush.
@@ -25,7 +25,7 @@ Session history viewer: hierarchy tree, message playback, debug mode, system pro
25
25
  Top Debug toggle (Bug icon), persisted in `localStorage` as `halo_session_debug`. When on:
26
26
  - **Normal mode**: user message + assistant reply (with inline tool-call cards, click to expand IN / OUT); sub-agent notifications hidden
27
27
  - **Debug mode**: adds, on top of the normal view:
28
- - **Thinking** blocks (purple, expandable, with char count) inside assistant messages
28
+ - **Thinking** blocks (purple, expandable, with char count) inside assistant messages. While a reply streams, its thinking shows in the same live panel as the normal view
29
29
  - **Usage** line after each turn — timestamp, token counts (in / out / ctx, cache read / write / hit ratio), latency (ttft / e2e), thinking effort, model ID
30
30
  - Sub-agent start / done markers labelled with the agent name
31
31
 
@@ -48,15 +48,15 @@ The Skills sidebar mirrors the Agents sidebar layout:
48
48
 
49
49
  ### Auto-sync behaviour (workspace vs. global)
50
50
 
51
- The Skills sidebar and any open mini-workspace editor stay in sync with the file system via `file:changed` WS events — but the server's watcher is scoped to the current workspace, so the guarantees differ by scope:
51
+ Every skill list — the chat's slash-command popup, the Skills sidebar, the agent form's skill picker — refetches on the `skills:changed` WS frame (→ `skill-bus` bump), in every open admin tab. The server sends it after every real mutation: REST create / delete / toggle and `/skill disable|enable|delete` from any chat or channel (a no-op toggle sends nothing). A workspace change reaches only that workspace's tabs; a global one reaches all. File-system edits differ by scope, because the server's watcher covers only the current workspace:
52
52
 
53
53
  | Change | Workspace skills | Global skills |
54
54
  |---|---|---|
55
- | Create / delete a skill dir | Live (watcher emits add/unlink → `skill-bus` bump → sidebar refetches) | Refreshed opportunistically on **window focus** |
55
+ | Via Skills tab / REST / `/skill` command | Live | Live |
56
+ | Create / delete a skill dir or edit SKILL.md frontmatter on disk (agent `file_write`, CLI / SSH) | Live (watcher → 300 ms debounce → `skills:changed` only when the folder ids / frontmatter changed) | Refreshed opportunistically on **window focus** while the Skills page is open |
56
57
  | Edit a file inside a skill while the mini-workspace editor has it open | Live (EditorPanel translates workspace-rel path into its own tab.path and refetches) | Live for the currently-open tab (same code path) — but the file tree change won't show new/removed sibling files until focus refresh |
57
- | Edit from external CLI / SSH | Same as above | Same as above |
58
58
 
59
- So when an agent (or the `skill` skill) creates a **global** skill, the Skills sidebar won't reflect it until the user refocuses the browser window. The `skill` skill documents this so agents can warn the user.
59
+ So when an agent writes a **global** skill folder directly, the lists won't reflect it until the user refocuses the browser window on the Skills page, or switches session / agent. The `skill` skill documents this so agents can warn the user.
60
60
 
61
61
  Extension skills follow the extension, not a file watcher. Installing or uninstalling an extension broadcasts `extension:changed`, which bumps `skill-bus`, so the sidebar and the agent form's skill picker refetch live. Edits inside an installed extension's `skills/` push nothing (show on focus refresh); reinstalling the extension is how a change ships.
62
62