@turmind/halo 1.1.6 → 1.1.8
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.
- package/admin-out/404.html +1 -1
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +1 -1
- package/bundled-docs/design/agentcore.md +1 -0
- package/bundled-docs/design/architecture.md +3 -0
- package/bundled-docs/design/cron.md +3 -2
- package/bundled-docs/design/feishu.md +2 -2
- package/bundled-docs/design/goal-mode.md +2 -0
- package/bundled-docs/design/session.md +24 -6
- package/bundled-docs/design/slack.md +2 -2
- package/bundled-docs/design/storage.md +4 -3
- package/bundled-docs/design/telegram.md +3 -3
- package/bundled-docs/design/wechat.md +9 -5
- package/bundled-docs/design/ws.md +2 -0
- package/bundled-docs/dev/add-channel.md +2 -2
- package/bundled-docs/dev/add-model-provider.md +1 -1
- package/bundled-docs/dev/deploy.md +1 -1
- package/bundled-docs/dev/env.md +2 -2
- package/bundled-docs/dev/tools.md +1 -1
- package/bundled-docs/guide/channels/wechat.md +1 -0
- package/bundled-docs/guide/cli.md +2 -2
- package/bundled-docs/requirements/settings.md +1 -1
- package/dist/index.js +1368 -1190
- package/package.json +1 -1
- package/templates/models/aws-bedrock-mantle.yaml +50 -7
- /package/admin-out/_next/static/{2BPMYobWiNR5zV_Tk9scZ → u4Su_gs1Pb6p_XeR45rCm}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{2BPMYobWiNR5zV_Tk9scZ → u4Su_gs1Pb6p_XeR45rCm}/_ssgManifest.js +0 -0
package/admin-out/404.html
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<!DOCTYPE html><!--
|
|
1
|
+
<!DOCTYPE html><!--u4Su_gs1Pb6p_XeR45rCm--><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/0d0ca7b3c8cc5562.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-fc1574554e7b1108.js"/><script src="/_next/static/chunks/fb4ec4d6-3fa44f69484f91b9.js" async=""></script><script src="/_next/static/chunks/635-d7fd95bff8674116.js" async=""></script><script src="/_next/static/chunks/main-app-483b436873410ddc.js" async=""></script><script src="/_next/static/chunks/212-70bd2971392bde84.js" async=""></script><script src="/_next/static/chunks/app/layout-c2fa8035998e48d5.js" async=""></script><meta name="robots" content="noindex"/><meta name="next-size-adjust" content=""/><title>404: This page could not be found.</title><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_f367f3 font-sans antialiased"><div hidden=""><!--$--><!--/$--></div><div style="font-family:system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div><style>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)}}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2></div></div></div><!--$--><!--/$--><script src="/_next/static/chunks/webpack-fc1574554e7b1108.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[59391,[\"212\",\"static/chunks/212-70bd2971392bde84.js\",\"177\",\"static/chunks/app/layout-c2fa8035998e48d5.js\"],\"ThemeProvider\"]\n3:I[26967,[\"212\",\"static/chunks/212-70bd2971392bde84.js\",\"177\",\"static/chunks/app/layout-c2fa8035998e48d5.js\"],\"AppI18nProvider\"]\n4:I[66048,[],\"\"]\n5:I[29926,[],\"\"]\n6:I[11789,[],\"OutletBoundary\"]\n8:I[21528,[],\"AsyncMetadataOutlet\"]\na:I[11789,[],\"ViewportBoundary\"]\nc:I[11789,[],\"MetadataBoundary\"]\nd:\"$Sreact.suspense\"\nf:I[79836,[],\"\"]\n:HL[\"/_next/static/media/e4af272ccee01ff0-s.p.woff2\",\"font\",{\"crossOrigin\":\"\",\"type\":\"font/woff2\"}]\n:HL[\"/_next/static/css/0d0ca7b3c8cc5562.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"0:{\"P\":null,\"b\":\"u4Su_gs1Pb6p_XeR45rCm\",\"p\":\"\",\"c\":[\"\",\"_not-found\"],\"i\":false,\"f\":[[[\"\",{\"children\":[\"/_not-found\",{\"children\":[\"__PAGE__\",{}]}]},\"$undefined\",\"$undefined\",true],[\"\",[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/0d0ca7b3c8cc5562.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_f367f3 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\":\"$undefined\",\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]}]}]}]]}]]}],{\"children\":[\"/_not-found\",[\"$\",\"$1\",\"c\",{\"children\":[null,[\"$\",\"$L4\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L5\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]]}],{\"children\":[\"__PAGE__\",[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"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.\"}]}]]}]}]],null,[\"$\",\"$L6\",null,{\"children\":[\"$L7\",[\"$\",\"$L8\",null,{\"promise\":\"$@9\"}]]}]]}],{},null,false]},null,false]},null,false],[\"$\",\"$1\",\"h\",{\"children\":[[\"$\",\"meta\",null,{\"name\":\"robots\",\"content\":\"noindex\"}],[[\"$\",\"$La\",null,{\"children\":\"$Lb\"}],[\"$\",\"meta\",null,{\"name\":\"next-size-adjust\",\"content\":\"\"}]],[\"$\",\"$Lc\",null,{\"children\":[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$d\",null,{\"fallback\":null,\"children\":\"$Le\"}]}]}]]}],false]],\"m\":\"$undefined\",\"G\":[\"$f\",[]],\"s\":false,\"S\":true}\n"])</script><script>self.__next_f.push([1,"b:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"1\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]\n7:null\n"])</script><script>self.__next_f.push([1,"10:I[89592,[],\"IconMark\"]\n9:{\"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\"}],[\"$\",\"$L10\",\"4\",{}]],\"error\":null,\"digest\":\"$undefined\"}\n"])</script><script>self.__next_f.push([1,"e:\"$9:metadata\"\n"])</script></body></html>
|
package/admin-out/index.html
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<!DOCTYPE html><!--
|
|
1
|
+
<!DOCTYPE html><!--u4Su_gs1Pb6p_XeR45rCm--><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/0d0ca7b3c8cc5562.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-fc1574554e7b1108.js"/><script src="/_next/static/chunks/fb4ec4d6-3fa44f69484f91b9.js" async=""></script><script src="/_next/static/chunks/635-d7fd95bff8674116.js" async=""></script><script src="/_next/static/chunks/main-app-483b436873410ddc.js" async=""></script><script src="/_next/static/chunks/212-70bd2971392bde84.js" async=""></script><script src="/_next/static/chunks/app/layout-c2fa8035998e48d5.js" async=""></script><script src="/_next/static/chunks/1d2d5650-810a3ca1ae20cab5.js" async=""></script><script src="/_next/static/chunks/438-866a1a20e9efa78a.js" async=""></script><script src="/_next/static/chunks/app/page-71c576815b4a30d9.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_f367f3 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-fc1574554e7b1108.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[59391,[\"212\",\"static/chunks/212-70bd2971392bde84.js\",\"177\",\"static/chunks/app/layout-c2fa8035998e48d5.js\"],\"ThemeProvider\"]\n3:I[26967,[\"212\",\"static/chunks/212-70bd2971392bde84.js\",\"177\",\"static/chunks/app/layout-c2fa8035998e48d5.js\"],\"AppI18nProvider\"]\n4:I[66048,[],\"\"]\n5:I[29926,[],\"\"]\n6:I[57917,[],\"ClientPageRoot\"]\n7:I[61089,[\"560\",\"static/chunks/1d2d5650-810a3ca1ae20cab5.js\",\"438\",\"static/chunks/438-866a1a20e9efa78a.js\",\"212\",\"static/chunks/212-70bd2971392bde84.js\",\"974\",\"static/chunks/app/page-71c576815b4a30d9.js\"],\"default\"]\na:I[11789,[],\"OutletBoundary\"]\nc:I[21528,[],\"AsyncMetadataOutlet\"]\ne:I[11789,[],\"ViewportBoundary\"]\n10:I[11789,[],\"MetadataBoundary\"]\n11:\"$Sreact.suspense\"\n13:I[79836,[],\"\"]\n:HL[\"/_next/static/media/e4af272ccee01ff0-s.p.woff2\",\"font\",{\"crossOrigin\":\"\",\"type\":\"font/woff2\"}]\n:HL[\"/_next/static/css/0d0ca7b3c8cc5562.css\",\"style\"]\n:HL[\"/_next/static/css/659eccb5db697b76.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"0:{\"P\":null,\"b\":\"u4Su_gs1Pb6p_XeR45rCm\",\"p\":\"\",\"c\":[\"\",\"\"],\"i\":false,\"f\":[[[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],[\"\",[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/0d0ca7b3c8cc5562.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_f367f3 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[89592,[],\"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>
|
package/admin-out/index.txt
CHANGED
|
@@ -14,7 +14,7 @@ e:I[11789,[],"ViewportBoundary"]
|
|
|
14
14
|
:HL["/_next/static/media/e4af272ccee01ff0-s.p.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
|
15
15
|
:HL["/_next/static/css/0d0ca7b3c8cc5562.css","style"]
|
|
16
16
|
:HL["/_next/static/css/659eccb5db697b76.css","style"]
|
|
17
|
-
0:{"P":null,"b":"
|
|
17
|
+
0:{"P":null,"b":"u4Su_gs1Pb6p_XeR45rCm","p":"","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/0d0ca7b3c8cc5562.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_f367f3 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
18
|
8:{}
|
|
19
19
|
9:"$0:f:0:1:2:children:1:props:children:0:props:params"
|
|
20
20
|
f:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
|
@@ -50,6 +50,7 @@ So **1 user = 1 runtime session id = 1 workspace**: users never see each
|
|
|
50
50
|
other's history; reconnecting with the same id resumes the same conversation.
|
|
51
51
|
`HALO_WORKSPACE` points at an EFS mount (`/mnt/efs`) — microVMs are ephemeral,
|
|
52
52
|
EFS makes the workspaces survive session termination and image rollouts.
|
|
53
|
+
`~/.halo/global/runs.db` (the [run ledger](session.md#run-ledger--restart-nudge-for-interrupted-roots-halo-globalrunsdb)) lives under the container's `HALO_HOME`, which is **not** on EFS — it dies with the microVM, so the restart nudge doesn't apply in agentcore mode (the writes themselves are harmless, just moot).
|
|
53
54
|
|
|
54
55
|
## WS protocol quirks (why /init exists)
|
|
55
56
|
|
|
@@ -170,6 +170,9 @@ File: `ws/terminal-manager.ts`. Spawns a shell via node-pty. On disconnect, deta
|
|
|
170
170
|
### Self-Evolution — workspace prompt-tuning loop
|
|
171
171
|
Files: `evolution/{ticker,evo-wrapper,enqueue,spawn,archive}.ts`, `db/evo-db.ts`, `routes/evolution.ts`. Internal agents `__evo_agent__` / `__score__` / `__apply_agent__` (in `templates/agents/`) drive a 12-phase orchestration: snapshot → evo drafts → wrapper dry-runs → scorer grades → reviewer approves → apply agent merges → wrapper history-snapshots + cps to main. Per-task wrapper Node child process owns all sub-cli calls; ticker is stateless and lives in the server. State in `~/.halo/global/evo.db`. See [plans/self-evolution.md](../plans/self-evolution.md).
|
|
172
172
|
|
|
173
|
+
### Run ledger — restart nudge for interrupted roots
|
|
174
|
+
Files: `agents/run-ledger.ts` (sweep + skip rules) + `db/runs-db.ts` (the global `~/.halo/global/runs.db` table `running_sessions`, same singleton pattern as `evo.db`/`cron.db`). `runSession` inserts its session id on entry and deletes it in the finally (server-only, gated on `reconcileOrphansOnBoot`), so whatever is left at boot is exactly what the previous process was mid-run on; the sweep drains those rows and nudges each interrupted root. `index.ts` runs this eagerly: right after constructing the `SessionManagerRegistry` and before `bootChannels`, it loops `listRunningWorkspaces()` and claims+builds a `SessionManager` for each one that isn't already owned by another live server, rather than waiting for someone to open the workspace. See [design/session.md](session.md#run-ledger--restart-nudge-for-interrupted-roots-halo-globalrunsdb).
|
|
175
|
+
|
|
173
176
|
## REST routes
|
|
174
177
|
|
|
175
178
|
See [dev/api.md](../dev/api.md).
|
|
@@ -53,9 +53,9 @@ Each fire:
|
|
|
53
53
|
2. Insert a `cron_runs` row with `status='running'` up front (UI sees it mid-flight); broadcast `cron:run_changed`.
|
|
54
54
|
3. Check `job.workspacePath` exists — fail loudly if the workspace was moved/deleted.
|
|
55
55
|
4. Spawn `halo cli -a <agent> -s cron-<jobId> -w <workspace>`, prompt on **stdin** (avoids Windows argv length limits). The stable `cron-<jobId>` session id means each fire *resumes the same session*, so the conversation accumulates and is reviewable in the admin Sessions tab. The child's pid is written back onto the `running` row (`cron_runs.pid`, nullable) — this is what makes the orphan sweep possible after a server crash. Note the **dual-writer layout** this creates: the cli child and the server share the workspace's session files. The server side is view-only for these sessions — admin subscribes seed a UIState from disk, and the WS detach-save skips it via the dirty gate (see [session.md](session.md#ws-disconnect-resilience)); without that gate a frozen server snapshot would overwrite the cli child's newer messages (the cron-session UI-log truncation incident). Live streaming of a cli-driven session into the admin is out of scope — events don't cross processes; the Sessions tab re-reads the file on open.
|
|
56
|
-
5. Tee stdout + stderr live to `~/.halo/global/logs/cron/<runId>.log`; capture stdout to memory for dispatch.
|
|
56
|
+
5. Tee stdout + stderr live to `~/.halo/global/logs/cron/<runId>.log`; capture stdout to memory for dispatch. Since 1.1.5 the cli's stdout is the **final reply of the last root turn** as raw markdown (stdout is not a TTY, so no terminal styling; the interim text the model emits before tool calls and the wrap-ups of earlier drained turns are not included — see [session.md](session.md#message-queue-and-drain) "Final-text flag"). Before that it was every streamed text block of the whole run, which for a director session absorbing a dozen sub-agent reports reached ~40 KB and blew the wechat gateway ceiling.
|
|
57
57
|
6. Enforce a **per-job timeout** (`job.timeoutSec ?? 3600`, the nullable `cron_jobs.timeout_sec` validated 60–21600 at every write surface; a change takes effect on the job's next fire, no croner re-instantiation needed) via a Node timer (cross-platform, not the Linux-only `timeout(1)`), with a **two-phase kill**: SIGTERM the direct child first — the cli has a SIGTERM handler that stops the harness, repairs + flushes the session, kills its own shell_exec process groups, and exits 143 — then, if it's still alive after a 30s grace, SIGKILL the entire descendant tree (POSIX ppid-walk over one `ps` snapshot; `taskkill /T /F` on Windows) so no grandchild survives to double-write the workspace on the next fire. A ppid walk, not a process-group kill: the cli sandbox spawns shell_exec workers `detached` into their own groups, so `kill(-pid)` would miss them. Timed-out runs still report exit code 124. Generous because overlap-of-the-same-job is already blocked up-front in step 1; this is just the long-stop reaper for a truly stuck child.
|
|
58
|
-
7. Classify: exit 124 → `timeout`; non-zero → `failed`; zero but empty stdout → `failed` ("nothing to dispatch"); else `succeeded`.
|
|
58
|
+
7. Classify: exit 124 → `timeout`; non-zero → `failed`; zero but empty stdout → `failed` ("nothing to dispatch" — with the final-only stdout contract this means the last turn produced no wrap-up text, not that the agent never spoke); else `succeeded`.
|
|
59
59
|
8. On success only, `dispatchToTargets(stdout, targets, job.workspacePath)` — the raw stdout goes down as-is; `MEDIA:` extraction happens per-target inside the dispatcher (see Dispatch model). If every target fails, downgrade to `failed`.
|
|
60
60
|
9. `finalize`: write the terminal `cron_runs` row, update `cron_jobs.lastRun*`, disable one-shot jobs, broadcast. The `_inflight` entry is released in `finally`, regardless of outcome.
|
|
61
61
|
|
|
@@ -93,6 +93,7 @@ interface CronChannelDispatcher {
|
|
|
93
93
|
- no `supportsMedia` (telegram, feishu) → the **original text with `MEDIA:` lines intact** — the attachment degrades to a visible path instead of silently vanishing.
|
|
94
94
|
- `CronMedia.workspacePath` is the **job's** workspace (where the run produced its files), not the target account's — it's the sandbox root for `isMediaPathAllowed` (paths outside workspace/OS-tmp are rejected with a failed result row).
|
|
95
95
|
- Wiring telegram/feishu up for real requires refactoring first, not copying: telegram's file send is an inline `sendPhoto/…/sendDocument` switch in `handler.ts`'s responder; feishu's `sendFeishuMedia` is anchored to an inbound `messageId` a cron run doesn't have. Extract the send-to-chat half in the channel module, then flip `supportsMedia: true` (details in each `cron-dispatcher.ts` header).
|
|
96
|
+
- **Per-channel size limits are the dispatcher's job**, not the runner's: wechat splits the text at `WECHAT_TEXT_LIMIT` (3500 chars, shared `splitText` from `channels/shared/chunk.ts`) and sends the chunks in order — the ilink gateway rejects any single `sendmessage` over 16 KB with `ret=-2`. A failure on chunk *i* of *n* is recorded as `chunk i/n: <error>` in the target's `DispatchResult`, so the admin run row shows how much of the report already landed (a `failed` row no longer means "nothing arrived"). Telegram still sends one shot (Bot API caps at 4096) — same class of gap, unfixed.
|
|
96
97
|
- **`chatId` semantics**: when a job is created from inside a chat, the target pins to that conversation (`chatId` set). When unset, the channel's own fan-out / default-recipient logic decides (e.g. Telegram fans out to its whitelist; Slack/Feishu require an explicit `chatId`).
|
|
97
98
|
- `listTargets()` aggregates across all dispatchers (`listAllCronTargets`) to feed the admin create-form dropdown without the routes knowing any channel.
|
|
98
99
|
|
|
@@ -139,9 +139,9 @@ Implemented via `channels/shared/commands.ts` (shared across Telegram, WeChat, S
|
|
|
139
139
|
|
|
140
140
|
Feishu's text message limit (~5000 chars), smaller than Slack. Strategy:
|
|
141
141
|
|
|
142
|
-
- Buffer streamed text until `complete` event
|
|
142
|
+
- Buffer only streamed text flagged `final` (the turn's wrap-up; pre-tool-call filler is dropped) until `complete` event
|
|
143
143
|
- Flush fires on **any** `complete` — the responder doesn't read its `batchBoundary` flag, so a multi-round queue drain ships each merged turn as its own message instead of one blob (see [session.md](session.md#message-queue-and-drain))
|
|
144
|
-
- If buffer exceeds 4500 chars, split at paragraph boundary (`\n\n`)
|
|
144
|
+
- If buffer exceeds 4500 chars, split at paragraph boundary (`\n\n`) via the shared `splitText` (`channels/shared/chunk.ts`)
|
|
145
145
|
- Otherwise hard-cut at 4500 chars
|
|
146
146
|
- `system` and `error` events flush early so users always see something before the run ends
|
|
147
147
|
- Media markers (`MEDIA:<path>`) intercepted and sent via native media upload
|
|
@@ -121,6 +121,8 @@ All five verbs are builtin deterministic code (`SUBCOMMAND_ROUTES`, no backing s
|
|
|
121
121
|
|
|
122
122
|
Continuation over death-handling: in-flight promises die with the process, goal state survives in the db. The SessionManager constructor (gated on `reconcileOrphansOnBoot` **and** the `.halo/runtime.lock` workspace claim — same ownership gate as the orphan reconcile) sweeps every goal at status `running` and delivers a deterministic nudge to G: *"server restarted, the in-flight round was lost; call goal_context, re-read GOAL_SPEC.md and your own transcript, re-dispatch."* Counters and caps were already in the db, so nothing is forgotten. `intake` needs no nudge (the user drives it); `paused` / `halted` / terminal goals stay put — a user-initiated pause still requires an explicit `/goal resume`.
|
|
123
123
|
|
|
124
|
+
Plain root sessions get the equivalent treatment from the run-ledger sweep (`sweepInterruptedRuns`, right after `sweepActiveGoals` in the same constructor chain — see [session.md](session.md#run-ledger--restart-nudge-for-interrupted-roots-halo-globalrunsdb)). A goal-bound W is excluded from that sweep **only** while its goal is `running` — that's the one state where this sweep above re-dispatches it, so a direct nudge would land a bogus round report on G. In `intake` or `paused`, G isn't re-dispatching anything, so W is nudged like any plain root.
|
|
125
|
+
|
|
124
126
|
## Admin surface & WS
|
|
125
127
|
|
|
126
128
|
- **`goal:changed`** is broadcast on every `writeGoalState` (and on the G-delete dissolve path) with `{goalSessionId, workerSessionId, status, round, maxRounds}`. The broadcast is **server-global with no workspace marker**; the admin re-fetches through the seed endpoint under its active project, which naturally filters cross-workspace events. See [ws.md](ws.md).
|
|
@@ -57,7 +57,7 @@ Full field list in [storage.md](storage.md).
|
|
|
57
57
|
- **`messages` (UI log): soft delete.** The target user message and every following message up to (not including) the next user message get `deleted: true`. The array length is unchanged, so `messageCount` semantics hold and the turn stays rendered — greyed out in the admin. There is no undo; a deleted exchange hides its own Delete button.
|
|
58
58
|
- **`rawMessages` (LLM-facing): physical delete of the whole turn.** Removed from the matched user-turn start through to the next user-turn start, so `tool_use` / `tool_result` pairs are never split (an orphaned `tool_result` would make every subsequent API call error out). `repairConversationMessages` then cleans the seam.
|
|
59
59
|
|
|
60
|
-
**Turn matching.** The raw turn is located by comparing the UI user text (after stripping the server-added `[图片已保存: …]` marker lines) against each raw user turn's text blocks, with an occurrence-rank tiebreak so duplicate prompts map to the right turn. **If no raw turn matches** (e.g. the turn was already compacted away) the raw log is left untouched — the UI soft-delete still lands (silent degrade).
|
|
60
|
+
**Turn matching.** The raw turn is located by comparing the UI user text (after stripping the server-added `[图片已保存: …]` marker lines) against each raw user turn's text blocks (after stripping the leading `[<iso>] ` arrival stamp `runAgentTurn` adds to every model-facing turn — `stripTurnStamp` in `sessions/session-store.ts`; the UI log keeps the raw text, so the two only compare equal once the stamp is gone), with an occurrence-rank tiebreak so duplicate prompts map to the right turn. **If no raw turn matches** (e.g. the turn was already compacted away) the raw log is left untouched — the UI soft-delete still lands (silent degrade).
|
|
61
61
|
|
|
62
62
|
**Ordinal alignment (the sharp edge).** `userOrdinal` is the 0-based index of the target user turn, counted **excluding `taskId` (sub-agent) messages** — matching the admin's `isMainConversationMessage` filter, which is what the frontend counts on. Both the ordinal-locate loop and the duplicate-rank loop skip `taskId` messages; if they didn't, a sub-agent's injected user turn in the root log would drift the count and delete the wrong exchange.
|
|
63
63
|
|
|
@@ -143,7 +143,7 @@ Hierarchical encoding: `root_id>child_segment>grandchild_segment`.
|
|
|
143
143
|
|
|
144
144
|
The full finally-chain order is `tryReportToParent → deliverGoalRound → releaseSession`: after the parent-report check, goal-bound **root** sessions get their round report delivered to the goal session (fire-and-forget; a no-op for everyone else — see [goal-mode.md](goal-mode.md#the-delivery-point-delivergoalround)), then the session is released.
|
|
145
145
|
|
|
146
|
-
- **Sibling-status injection for root** (`siblingStatusSuffix`): `tryReportToParent` early-returns for root (`parentId === null`) since there's no parent to bubble up to — so root never learns whether its *other* children are still running, and the root LLM could wrap up early after consuming just one child's report. When root consumes a child report (`querySession` idle branch + `drainQueue`), a sibling-status line is appended to the message fed to the LLM. "All sub-agents done" requires **both** no sibling running in the DB (`parentId = root AND stoppedAt IS NULL`) **and** an empty in-memory `messageQueue` — a child can be stopped while its report is still queued, so the DB check alone would falsely declare completion. The reporting child needs no identity exclusion: it stamped `stoppedAt` before the report was delivered, so `stoppedAt IS NULL` already excludes it (unless re-dispatched a new task, which clears `stoppedAt` — then it correctly counts as running). The line carries per-child `created` + `last active` timestamps so a capable model can tell a freshly dispatched sibling from an original-batch leftover. Mid-tier parents are excluded by design — their `tryReportToParent` bubble-up already gates them on a fully-drained subtree.
|
|
146
|
+
- **Sibling-status injection for root** (`siblingStatusSuffix`): `tryReportToParent` early-returns for root (`parentId === null`) since there's no parent to bubble up to — so root never learns whether its *other* children are still running, and the root LLM could wrap up early after consuming just one child's report. When root consumes a child report (`querySession` idle branch + `drainQueue`), a sibling-status line is appended to the message fed to the LLM. "All sub-agents done" requires **both** no sibling running in the DB (`parentId = root AND stoppedAt IS NULL`) **and** an empty in-memory `messageQueue` — a child can be stopped while its report is still queued, so the DB check alone would falsely declare completion. The reporting child needs no identity exclusion: it stamped `stoppedAt` before the report was delivered, so `stoppedAt IS NULL` already excludes it (unless re-dispatched a new task, which clears `stoppedAt` — then it correctly counts as running). The line carries per-child `created` + `last active` timestamps so a capable model can tell a freshly dispatched sibling from an original-batch leftover. (Its `[System @ <iso>]` header used to be the *only* wall-clock signal the model ever saw; since 1.1.6 every turn carries an arrival stamp — see [Message queue and drain](#message-queue-and-drain).) Mid-tier parents are excluded by design — their `tryReportToParent` bubble-up already gates them on a fully-drained subtree.
|
|
147
147
|
|
|
148
148
|
- **interruptSession**: fire-and-forget abort of the in-flight turn — it sets `interruptRequested` so `runAgentTurn`'s unwind repairs (not errors), then aborts. It does **not** await or re-run: once the aborted turn unwinds, `runSession`'s finally sees the non-empty queue and `drainQueue` folds the queued message into one merged follow-up turn. The `interrupt_session` tool reaches this via `querySession(..., interrupt=true)` (enqueue + abort), so there is no separate re-run path or `skipRelease` bookkeeping.
|
|
149
149
|
|
|
@@ -151,7 +151,21 @@ Hierarchical encoding: `root_id>child_segment>grandchild_segment`.
|
|
|
151
151
|
|
|
152
152
|
A sub-session whose process was killed mid-run never got its `stoppedAt` written, so it stays `stoppedAt IS NULL` forever — displaying as a false "running" and permanently blocking its parent's auto-report bubbling (`tryReportToParent` sees a "live" child that will never report back). When the server process first builds a workspace's SessionManager, `reconcileOrphansOnBoot` batch-stamps `stoppedAt` on every non-root, non-stopped, non-archived session. Only the long-lived server passes `reconcileOrphansOnBoot: true` through the registry — CLI/TUI/channel-subprocess/evo-wrapper share the same db while the server may be running sessions, so they never reconcile. If an orphan is later revived via `query_session`, that path clears `stoppedAt` again, so nothing is trapped permanently.
|
|
153
153
|
|
|
154
|
-
**Workspace-level gate (`.halo/runtime.lock`)**: `server.lock` ownership alone is not sufficient — two servers with different `HALO_HOME` (e.g. prod + dev) each hold their own `server.lock` yet can point at the *same* workspace directory, and one server's boot reconcile would batch-stop the other's actually-live sub-sessions (the incident this gate exists for). So the reconcile additionally requires `claimWorkspaceRuntime(workspaceRoot)` (`agents/workspace-runtime-lock.ts`) — a pid marker at `<workspace>/.halo/runtime.lock` with a liveness probe. Claim fails → skip reconcile and log a warning: **prefer missing a crash-orphan cleanup over stopping another process's live sessions**. `reconcileOrphansOnBoot: true` therefore means "reconcile if the workspace claim succeeds," not "always." Lock protocol details in [storage.md](storage.md#workspace-runtime-lock); known residual: when two servers both actively use one workspace long-term, the non-owner never reconciles — its own crash orphans stay un-cleaned until the owner restarts and takes over.
|
|
154
|
+
**Workspace-level gate (`.halo/runtime.lock`)**: `server.lock` ownership alone is not sufficient — two servers with different `HALO_HOME` (e.g. prod + dev) each hold their own `server.lock` yet can point at the *same* workspace directory, and one server's boot reconcile would batch-stop the other's actually-live sub-sessions (the incident this gate exists for). So the reconcile additionally requires `claimWorkspaceRuntime(workspaceRoot)` (`agents/workspace-runtime-lock.ts`) — a pid marker at `<workspace>/.halo/runtime.lock` with a liveness probe. Claim fails → skip reconcile and log a warning: **prefer missing a crash-orphan cleanup over stopping another process's live sessions**. `reconcileOrphansOnBoot: true` therefore means "reconcile if the workspace claim succeeds," not "always." Lock protocol details in [storage.md](storage.md#workspace-runtime-lock); known residual: when two servers both actively use one workspace long-term, the non-owner never reconciles — its own crash orphans stay un-cleaned until the owner restarts and takes over. The run-ledger eager boot loop below deals with the *un-opened* case for the same residual: a workspace whose runtime another live process owns is skipped before a SessionManager is even built, so the rows wait for a later boot instead of freezing behind a cached non-owner instance.
|
|
155
|
+
|
|
156
|
+
The constructor chain is now `reconcile orphans → sweepActiveGoals → sweepInterruptedRuns` — all three gated on the same `reconcileOrphansOnBoot` + `.halo/runtime.lock` claim, so a workspace that fails the claim runs none of them.
|
|
157
|
+
|
|
158
|
+
### Run ledger — restart nudge for interrupted roots (`~/.halo/global/runs.db`)
|
|
159
|
+
|
|
160
|
+
The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the **root** the server restarted — asked "done yet?" it still answers "waiting on reports" forever. Goal sessions already had a restart nudge (`sweepActiveGoals`); plain roots didn't, because "which session is running right now" only ever lived in memory.
|
|
161
|
+
|
|
162
|
+
**Ledger** (`db/runs-db.ts`): a global `~/.halo/global/runs.db` (same singleton pattern as `evo.db` / `cron.db` — `setRunsDb`/`getRunsDb`, WAL), one table `running_sessions(workspace TEXT, session_id TEXT, started_at INTEGER, PK(workspace, session_id))`. `runSession` inserts the id on entry and deletes it in its `finally`, so the steady state is an **empty table** — whatever is still there at boot is exactly the set of runs the previous process died in the middle of; no pid, no liveness probe needed. Only the long-lived server writes: `SessionManager.ledgerEnabled = opts.reconcileOrphansOnBoot === true`, the same flag that gates the orphan reconcile — cli / cron / TUI / evo-wrapper runs end with their terminal and never enter the table. Writes go through a private `ledgerWrite` wrapper that try/catches and only `console.warn('[RunLedger] ledger write failed …')` on failure: a sqlite hiccup (disk full, EIO) must not drop a message or strand a session at `promise = null` without a `complete` — worst case is one stale/missing row, i.e. one spurious or missed nudge at next boot.
|
|
163
|
+
|
|
164
|
+
**Sweep** (`sweepInterruptedRuns`, `agents/run-ledger.ts`): runs from the SessionManager constructor right after `sweepActiveGoals`, under the same ownership gate. `drainRunning(workspace)` reads and deletes that workspace's rows in one transaction **before** any nudge goes out — the invariant being that a nudge's own re-dispatch inserts a fresh row, which must never be mistaken for a leftover on some future sweep. Ids are grouped by root (`id.split('>')[0]`); each root gets one append-then-send nudge (text starts `[System] The server restarted at <ISO> while you were mid-run…`, telling it sub-agents are stopped-but-revivable via `query_session`; sub-sessions are not listed — the root's own transcript / `session_list` has them). Skipped per root: row missing or `archivedAt` set; `goal` column set (a goal session — `sweepActiveGoals` owns its nudge); a goal-bound worker whose goal is `running` (G's own restart nudge re-dispatches it; a direct nudge would land a bogus round report on G) — but **only** `running`: `intake`/`paused` workers are nudged like plain roots, safe because `sendUserMessage` bypasses the routing overlay and `deliverGoalRound` returns early for a non-running goal; `cron-*` ids; `internal: true` agents (synchronous read of `agent.yaml`, since the sweep runs from a sync constructor). Principle: exactly one entry point gets woken per tree — a plain tree wakes its root, a `running` goal tree wakes G, a cron tree wakes nobody (it waits for its next fire).
|
|
165
|
+
|
|
166
|
+
**Eager boot** (`index.ts`): rather than waiting for someone to open a workspace, right after constructing `SessionManagerRegistry` (before `bootChannels`) the server calls `listRunningWorkspaces()` and for each workspace with leftover rows whose `.halo` dir still exists: `claimWorkspaceRuntime(ws)` first — if another live server owns it, `continue` (rows stay for a later boot, and no SessionManager is built, since caching a non-owner SM would freeze "not owner" for the whole process lifetime) — otherwise `registry.getOrCreate(ws)`, which fires the same constructor chain (claim → reconcile → `sweepActiveGoals` → `sweepInterruptedRuns`). An interrupted root with nobody around is exactly the one that must nudge itself without a human opening the tab first.
|
|
167
|
+
|
|
168
|
+
`runs.db`/`-wal`/`-shm` join the sandbox `hidden_files` default alongside `evo.db` / `cron.db` (see [storage.md](storage.md)). Known gap carried from the design doc: `<ISO>` in the nudge is sweep time, not the actual interruption time — for a workspace that sat locked by another server before this boot could claim it, that's a real but accepted drift.
|
|
155
169
|
|
|
156
170
|
### Message queue and drain
|
|
157
171
|
|
|
@@ -159,12 +173,16 @@ A sub-session whose process was killed mid-run never got its `stoppedAt` written
|
|
|
159
173
|
|
|
160
174
|
A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is set for agent→agent entries (drives the `(from: session X)` prefix and the sibling-status suffix) and absent for user messages; `images` is the user multimodal payload (agent entries have none).
|
|
161
175
|
|
|
162
|
-
**runSession loop** (`runSession(sessionId, message)`, `message: string | ContentBlock[]`): runs the opening turn when there is one, then drains. An empty **string** message means "the work is already in `messageQueue`" (the `querySession` idle path) — it skips the opening turn and goes straight to drain. The per-turn reset (fresh `toolCallLog` / `warnedToolHashes`, `interruptRequested = false`) runs before the opening turn; `drainQueue` repeats the same reset before each merged batch so a stale interrupt flag never leaks across turns.
|
|
176
|
+
**runSession loop** (`runSession(sessionId, message)`, `message: string | ContentBlock[]`): runs the opening turn when there is one, then drains. An empty **string** message means "the work is already in `messageQueue`" (the `querySession` idle path) — it skips the opening turn and goes straight to drain. The per-turn reset (fresh `toolCallLog` / `warnedToolHashes`, `interruptRequested = false`) runs before the opening turn; `drainQueue` repeats the same reset before each merged batch so a stale interrupt flag never leaks across turns. Server-only: right before `session.promise = runFn()` it inserts the session id into the run ledger, and in the `finally` (right after `session.promise = null`, before emitting `complete`) it deletes it — see [Run ledger](#run-ledger--restart-nudge-for-interrupted-roots-halo-globalrunsdb) above.
|
|
177
|
+
|
|
178
|
+
**Arrival stamp.** Both paths run the turn through `runAgentTurn`, which prepends `[<ISO-8601 UTC>] ` (e.g. `[2026-09-12T15:17:44.153Z] `) to the first text block of the model-facing input — an image-only input gets a stamp-only text block unshifted. The model has no clock; without this it couldn't tell a reply that came two days later from one that came instantly, or how long a sub-agent report took to land. Stamped **once, before the retry loop**, so every attempt (and the [multimodal 4xx degrade](#multimodal-4xx-degrade) rebuild) reuses the identical input. Agent reports therefore read `[<iso>] (from: session X)\n…` — stamp outermost, no special-casing. Only `agent.messages` changes: the UI `type:'user'` event keeps the raw text (see [Turn matching](#exchange-deletion-soft-ui--hard-raw) for the strip on compare), and the self-compact instruction / `[Conversation Summary]` injection / `tool_result` messages are never stamped.
|
|
179
|
+
|
|
180
|
+
**Final-text flag.** The agent loop marks each text block `final` when the model call ended for a reason other than `tool_use` (i.e. it is the turn's wrap-up, not the filler emitted before a tool call), and `runAgentTurn`'s `case 'text'` forwards it on the `stream` event as `AgentSessionEvent.final`. Block-send consumers gate on it: the channel responders (wechat / telegram / slack / feishu) append only `final` text, and `halo cli` prints the last root turn's `final` text as its stdout (fallback: that turn's full text). `finalOutput` (used by `tryReportToParent` / `deliverGoalRound`) is the same accumulation on the server side.
|
|
163
181
|
|
|
164
182
|
**drainQueue** folds the **whole queue** into ONE merged follow-up turn per round, re-checking after each round (a fresh interrupt or a sibling's report can land mid-drain):
|
|
165
183
|
- The batch is `splice(0)`'d; agent entries keep a `(from: session X)` prefix, user entries fold raw, and all entries' `images` are merged in.
|
|
166
184
|
- The `siblingStatusSuffix` is appended **only** when the batch carries at least one agent report (`batch.some(sourceSessionId)`) — a pure-user batch must not trigger "all sub-agents completed" noise.
|
|
167
|
-
- **`queued_message` is emitted here and only here** — once per merged batch, root only (it opens a fresh streaming assistant bubble; the text is cosmetic, downstream reads only `{chat:followup, agentName}`). `querySession`'s enqueue path emits just a `type:'user'` trace, never `queued_message`, so N reports folding into one turn produce **one** bubble, not N ghost bubbles.
|
|
185
|
+
- **`queued_message` is emitted here and only here** — once per merged batch, root only (it opens a fresh streaming assistant bubble; the text is cosmetic, downstream reads only `{chat:followup, agentName}`). `querySession`'s enqueue path emits just a `type:'user'` trace, never `queued_message`, so N reports folding into one turn produce **one** bubble, not N ghost bubbles. `halo cli` also relies on this event as its "new root turn" boundary to reset its per-turn text buffers (no `complete` separates the opening turn from the first drained turn), so it must stay unconditional.
|
|
168
186
|
|
|
169
187
|
**`complete` invariants** (root only):
|
|
170
188
|
- The **terminal** `complete` is emitted from `runSession`'s `finally`, and `promise = null` is set **before** emitting it — the CLI / web stream-close logic gates on `complete && !hasRunningSessions()`, which reads `promise !== null`; emitting `complete` first would leave the session still "running" at the moment the client decides whether to close.
|
|
@@ -327,7 +345,7 @@ Frontend network issues don't affect the backend:
|
|
|
327
345
|
|
|
328
346
|
### Multimodal 4xx degrade
|
|
329
347
|
|
|
330
|
-
A rejected image block doesn't just fail one turn: history is replayed wholesale with every request, so the same block re-fails **all** later requests and the session is permanently bricked. When a 4xx carries a known multimodal-rejection fingerprint (`Multimodal data is corrupted` / `Could not process image` — deliberately narrow: a miss keeps current behavior, a false positive would strip images on an unrelated 400), `replaceImageBlocks()` swaps **every** image block in history — top-level *and* nested inside `tool_result` content (e.g. `view_image`) — for a `[image removed: …]` text placeholder. The current turn's input blocks are degraded the same way (the retry re-coalesces them into the trailing user message, so a live rejected image would walk right back into history). State is persisted immediately via `saveAgentState` (a crash before turn-end must not resurrect the rejected blocks from disk), then the turn retries. Naturally once-only: after a degrade no image blocks remain, so a repeat error finds `replaced === 0` and falls through to Unrecoverable.
|
|
348
|
+
A rejected image block doesn't just fail one turn: history is replayed wholesale with every request, so the same block re-fails **all** later requests and the session is permanently bricked. When a 4xx carries a known multimodal-rejection fingerprint (`Multimodal data is corrupted` / `Could not process image` — deliberately narrow: a miss keeps current behavior, a false positive would strip images on an unrelated 400), `replaceImageBlocks()` swaps **every** image block in history — top-level *and* nested inside `tool_result` content (e.g. `view_image`) — for a `[image removed: …]` text placeholder. The current turn's input blocks are degraded the same way (the retry re-coalesces them into the trailing user message, so a live rejected image would walk right back into history); this rebuild works on the already-stamped copy of the input, so the arrival stamp survives the degrade and is never re-applied. State is persisted immediately via `saveAgentState` (a crash before turn-end must not resurrect the rejected blocks from disk), then the turn retries. Naturally once-only: after a degrade no image blocks remain, so a repeat error finds `replaced === 0` and falls through to Unrecoverable.
|
|
331
349
|
|
|
332
350
|
Prevention at the entry boundary: `buildInput` whitelists inbound image media types against `VISION_IMAGE_MIME_TYPES` (jpeg/png/gif/webp — single source in `channels/shared/media-store.ts`, shared with the web channel's inbound filter), so a bmp/tiff/empty `file.type` from the admin WS paste fallback never reaches the model in the first place.
|
|
333
351
|
|
|
@@ -108,9 +108,9 @@ Unlike Telegram (grammy polling) or WeChat (HTTP long-poll), Socket Mode elimina
|
|
|
108
108
|
### Outbound (SlackResponder)
|
|
109
109
|
|
|
110
110
|
1. SessionManager emits AgentSessionEvent stream chunks
|
|
111
|
-
2. SlackResponder buffers chunks until `complete` event
|
|
111
|
+
2. SlackResponder buffers only chunks flagged `final` (the turn's wrap-up text; pre-tool-call filler is dropped) until `complete` event
|
|
112
112
|
3. On `complete` or when buffer hits 35k chars:
|
|
113
|
-
- Split at paragraph boundary if needed (Slack hard-caps messages at ~40k)
|
|
113
|
+
- Split at paragraph boundary if needed via the shared `splitText` (`channels/shared/chunk.ts`; Slack hard-caps messages at ~40k)
|
|
114
114
|
- Extract `MEDIA: <path>` markers (sent as native file uploads)
|
|
115
115
|
- Convert CommonMark → Slack's mrkdwn format
|
|
116
116
|
- Post as a single message (or series of messages if split)
|
|
@@ -25,6 +25,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
25
25
|
│ │ └── <sessionId>.json
|
|
26
26
|
│ ├── evo.db # Cross-workspace evolution queue (evolution_runs + evolution_applies)
|
|
27
27
|
│ ├── cron.db # Cross-workspace cron jobs + run history (cron_jobs + cron_runs)
|
|
28
|
+
│ ├── runs.db # Run ledger: (workspace, session_id) rows for server-driven runs in flight; steady state empty (running_sessions)
|
|
28
29
|
│ ├── logs/ # Runtime logs
|
|
29
30
|
│ │ ├── evo/ # Per-evo-run wrapper logs
|
|
30
31
|
│ │ └── cron/ # Per-cron-run cli stdout/stderr (30-day retention)
|
|
@@ -112,7 +113,7 @@ Path: `.halo/sessions/{agentId}/{sessionId}.json`
|
|
|
112
113
|
"archiveCount": 2, // committed UI-log archive segments; absent = never archived
|
|
113
114
|
"archivedUserCount": 30, // main user turns that moved into segments
|
|
114
115
|
"messages": [SessionMessage], // UI event-log format (written by WS handler / UIState reducer)
|
|
115
|
-
"rawMessages": [AnthropicMessage], // raw Bedrock API shape (written by SessionManager saveAgentState)
|
|
116
|
+
"rawMessages": [AnthropicMessage], // raw Bedrock API shape (written by SessionManager saveAgentState); user text blocks start with the `[<iso>] ` arrival stamp — strip it (stripTurnStamp) before matching against `messages`
|
|
116
117
|
"output": "..." // accumulated assistant text from the latest turn
|
|
117
118
|
}
|
|
118
119
|
```
|
|
@@ -327,7 +328,7 @@ general: # built-in declarer (the server itself
|
|
|
327
328
|
summarize_timeout_sec: 300
|
|
328
329
|
sandbox:
|
|
329
330
|
hidden_dirs: "~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh"
|
|
330
|
-
hidden_files: "~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc"
|
|
331
|
+
hidden_files: "~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/evo.db,~/.halo/global/evo.db-wal,~/.halo/global/evo.db-shm,~/.halo/global/cron.db,~/.halo/global/cron.db-wal,~/.halo/global/cron.db-shm,~/.halo/global/runs.db,~/.halo/global/runs.db-wal,~/.halo/global/runs.db-shm"
|
|
331
332
|
logging:
|
|
332
333
|
level: warn
|
|
333
334
|
|
|
@@ -383,7 +384,7 @@ A value of the form `<<ENV_NAME>>` is replaced at read time with `process.env.EN
|
|
|
383
384
|
| `general.limits.ws_event_buffer` | 5000 | Events buffered per detached WS session before oldest are dropped on reattach |
|
|
384
385
|
| `general.limits.terminal_scrollback_bytes` | 50000 | Off-screen scrollback bytes retained per detached persistent terminal |
|
|
385
386
|
| `general.sandbox.hidden_dirs` | `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh` | bwrap tmpfs overlay (Linux only) |
|
|
386
|
-
| `general.sandbox.hidden_files` | `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc` | bwrap /dev/null bind (Linux only) |
|
|
387
|
+
| `general.sandbox.hidden_files` | `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/evo.db,~/.halo/global/evo.db-wal,~/.halo/global/evo.db-shm,~/.halo/global/cron.db,~/.halo/global/cron.db-wal,~/.halo/global/cron.db-shm,~/.halo/global/runs.db,~/.halo/global/runs.db-wal,~/.halo/global/runs.db-shm` | bwrap /dev/null bind (Linux only) |
|
|
387
388
|
| `general.logging.level` | warn | `debug` / `info` / `warn` / `error` |
|
|
388
389
|
|
|
389
390
|
Schema source: [packages/server/src/settings-schema.ts](../../../packages/server/src/settings-schema.ts) `generalSection()`.
|
|
@@ -21,7 +21,7 @@ All four IM channels (telegram / wechat / slack / feishu) route inbound messages
|
|
|
21
21
|
|
|
22
22
|
Three pieces:
|
|
23
23
|
|
|
24
|
-
- **`InboundBridge<Route>`** — per-account bookkeeping for session listeners + their reply routes (`setRoute` / `getRoute` / `ensureListener` / `dropListener` / `closeAll`). The **reply route** — where to send, plus any per-message credential like wechat's context token — is a per-session value refreshed on **every** inbound message, and responders read it lazily at send time via `bridge.getRoute(sessionId)`. A listener registered once therefore can't lock onto a stale destination. Route entries are dropped together with their listener, so the map can't outgrow the listener set (the old per-channel context-token map never got cleaned up) — but **only once the responder has drained**: `close()` may return a promise (slack / feishu serialize their per-chunk sends and hand one back; telegram
|
|
24
|
+
- **`InboundBridge<Route>`** — per-account bookkeeping for session listeners + their reply routes (`setRoute` / `getRoute` / `ensureListener` / `dropListener` / `closeAll`). The **reply route** — where to send, plus any per-message credential like wechat's context token — is a per-session value refreshed on **every** inbound message, and responders read it lazily at send time via `bridge.getRoute(sessionId)`. A listener registered once therefore can't lock onto a stale destination. Route entries are dropped together with their listener, so the map can't outgrow the listener set (the old per-channel context-token map never got cleaned up) — but **only once the responder has drained**: `close()` may return a promise (slack / feishu / wechat serialize their per-chunk sends and hand one back; telegram still fires each chunk and returns `void`), and the bridge defers the route deletion until it settles. Dropping the route synchronously stranded the tail of a split reply with nowhere to send (audit A-L3). `ensureListener` is idempotent per session; the account runner owns the bridge so `stopAccount` tears everything down with `closeAll()`, which walks the same per-listener teardown — deliberately no blanket `routes.clear()`, since that would delete the routes in-flight sends are about to read.
|
|
25
25
|
- **`deliverInbound`** — the tail itself: `rememberLastActiveChat` → access-level projection → resolve-or-create the channel session → `resolveGoalRoute` overlay → busy/compacting hint (hint only; delivery continues regardless) → `setRoute` → `ensureListener` → `appendUserMessage(uiText)` + `sendUserMessage("[channel: X | user: Y | thread: Z]\n" + agentText)`. Returns the (goal-routed) session id.
|
|
26
26
|
- **`dispatchChannelCommand`** — wraps the shared `dispatchCommand` and, when the result says `startedTurn`, wires route + listener exactly like the plain-message path, so no channel can forget it again. A call site that can't build a route logs instead of attaching a responder that could never send.
|
|
27
27
|
|
|
@@ -118,9 +118,9 @@ Photos / documents / voice / video notes trigger the same flow with the saved-fi
|
|
|
118
118
|
## Event coalescing (TelegramResponder)
|
|
119
119
|
|
|
120
120
|
Same strategy as WeChat:
|
|
121
|
-
- Buffer until `complete
|
|
121
|
+
- Buffer only `stream` events flagged `final` (the turn's wrap-up text; the filler the model emits before a tool call is dropped) until `complete`, then flush as one message
|
|
122
122
|
- Flush happens on **any** `complete` — the responder doesn't read its `batchBoundary` flag, so a multi-round queue drain ships each merged turn as its own message instead of one blob (see [session.md](session.md#message-queue-and-drain))
|
|
123
|
-
- If buffer exceeds 4000 chars (Telegram limit), split at paragraph boundary
|
|
123
|
+
- If buffer exceeds 4000 chars (Telegram limit), split at paragraph boundary via the shared `splitText` (`channels/shared/chunk.ts`, used by all four responders)
|
|
124
124
|
- `MEDIA:<path>` markers are intercepted and sent as native Telegram media
|
|
125
125
|
- Errors flush immediately
|
|
126
126
|
- Sub-agent events are dropped (visible in web UI only)
|
|
@@ -46,10 +46,11 @@ Sub-sessions inherit the parent's access level — a readonly channel can't dele
|
|
|
46
46
|
|
|
47
47
|
WeChat bot accounts are stored in the unified channel DB: `~/.halo/secrets/channels/channels.db`, table `channel_accounts` with `channel_type = 'wechat'`. See [storage.md](storage.md#channel_accounts) for the full schema.
|
|
48
48
|
|
|
49
|
-
WeChat-specific config JSON fields: `botToken`, `baseUrl`, `userId`, `syncBuf`, `lastActiveChatId`.
|
|
49
|
+
WeChat-specific config JSON fields: `botToken`, `baseUrl`, `userId`, `syncBuf`, `lastActiveChatId`, `contextTokens`.
|
|
50
50
|
|
|
51
51
|
- `userId` is the QR-bind owner (the ilink_user_id of whoever scanned the QR code to register this bot). Used by the wechat cron-dispatcher as the default proactive-send target — sending here means cron output goes back to the bot's owner, which is the common-case "report to me on a schedule" intent.
|
|
52
52
|
- `lastActiveChatId` is a runtime cache of the most recent inbound `from_user_id`. Written by `rememberLastActiveChat()` in `channels/shared/accounts.ts` with a per-process hash so unchanged values never touch the db (idempotent — hot path stays in memory). Used as a fallback for cron sends in the shared-bot case where you want to reply to whoever talked last.
|
|
53
|
+
- `contextTokens` is a `Record<userId, context_token>` — the most recent inbound `context_token` per user, written by `rememberWechatContextToken()` (same in-process dedupe / write-on-change shape as `lastActiveChatId`). The ilink gateway expects every outbound `sendmessage` to echo the recipient's latest inbound token; the chat-reply path already carries it in the in-memory bridge route, but cron dispatch runs long after the inbound and needs a persisted copy.
|
|
53
54
|
|
|
54
55
|
### Proactive sending (cron)
|
|
55
56
|
|
|
@@ -61,6 +62,8 @@ The wechat cron-dispatcher (`channels/wechat/cron-dispatcher.ts`) registers itse
|
|
|
61
62
|
|
|
62
63
|
WeChat is single-recipient per dispatch (no fan-out across `allowedUsers` like telegram has). If none of the three yields an id, dispatch fails with a clear "no wechat target — bind the account first" message.
|
|
63
64
|
|
|
65
|
+
The text is chunked exactly like a chat reply — `splitText(text, WECHAT_TEXT_LIMIT)` (3500 chars, see [Event coalescing](#event-coalescing-wechatresponder)) — and the chunks are sent sequentially so a long report arrives in order. Before this, a whole report went out in one `sendmessage` call and anything over the gateway's 16 KB ceiling failed outright with `ret=-2 "prepare failed"` (the stock-report job hit it routinely). A mid-chunk failure rethrows as `chunk i/n: <error>`, so the run row's dispatch result shows how much of the report already landed (chunks before `i` were delivered). Every chunk carries the recipient's persisted `context_token` (`config.contextTokens[chatId]`, see above) — the ilink gateway expects outbound to echo the latest inbound token, and sending without it was the second `ret=-2` cause: 6 failures in 10 days on payloads far under 16 KB, one of them 6 minutes after the user had just been chatting with the bot, which rules out the earlier "no recent inbound message" theory. Accounts with no inbound since 1.1.8 have no token yet and still send bare (as before) until the owner's next message. `api.ts`'s `ret=-2` hint names both causes (oversized payload / missing token).
|
|
66
|
+
|
|
64
67
|
## Modules
|
|
65
68
|
|
|
66
69
|
Files: `packages/server/src/channels/wechat/`
|
|
@@ -72,7 +75,7 @@ Files: `packages/server/src/channels/wechat/`
|
|
|
72
75
|
- `media-store.ts` (in `channels/shared/`) — saves inbound media to the workspace. Shared with the web chat channel: WeChat uses `<workspace>/.halo/assets/weixin/inbound/<accountId>/<date>/` (the `weixin` path segment is kept for backward compatibility with already-saved assets — the code, routes and db all say `wechat`), web pasted images land in `<workspace>/.halo/assets/web/inbound/<date>/`. Both emit `[图片已保存: /abs/path]` markers that the UI turns into media chips.
|
|
73
76
|
- `send-media.ts` — uploads and sends files/images out
|
|
74
77
|
- `handler.ts` — long-poll main loop, routes messages to SessionManager + slash-command dispatch
|
|
75
|
-
- `event-adapter.ts` — LLM streaming events → WeChat whole-message send (
|
|
78
|
+
- `event-adapter.ts` — LLM streaming events → WeChat whole-message send (buffer `final` stream text only, cut at `WECHAT_TEXT_LIMIT` via shared `splitText`, flush on complete, serialized send chain)
|
|
76
79
|
|
|
77
80
|
## SessionManager dependencies
|
|
78
81
|
|
|
@@ -117,10 +120,11 @@ Unknown `/` input falls through to normal message handling.
|
|
|
117
120
|
|
|
118
121
|
## Event coalescing (WechatResponder)
|
|
119
122
|
|
|
120
|
-
WeChat `sendMessage` is block-send, while LLMs stream. The current strategy is "buffer the
|
|
121
|
-
- `stream` events accumulate in a single buffer
|
|
123
|
+
WeChat `sendMessage` is block-send, while LLMs stream. The current strategy is "buffer the turn's reply, flush as one message":
|
|
124
|
+
- Only `stream` events flagged `final` (the wrap-up text of a turn — see [session.md](session.md#message-queue-and-drain)) accumulate in a single buffer; the filler the model emits before a tool call is dropped (it stays in the web UI)
|
|
122
125
|
- `complete` → flush the whole buffer as one (or more) messages. The responder flushes on **any** `complete` and does not read its `batchBoundary` flag — so when the root drains a multi-message queue across several merged turns (see [session.md](session.md#message-queue-and-drain)), each round's batch-boundary `complete` ships that round as its own message instead of buffering every round into one blob that lands only at the terminal `complete` (the "8 reports in one lump" bug)
|
|
123
|
-
- Hard cap `
|
|
126
|
+
- Hard cap `WECHAT_TEXT_LIMIT = 3500` **chars** (JS string length, not bytes — the ilink gateway rejects a `sendmessage` body over 16 KB with `ret=-2 "prepare failed"`; 3500 chars is ≤ ~10.5 KB of CJK plus the JSON envelope). When the buffer exceeds it mid-stream, the shared `splitText` (`channels/shared/chunk.ts`) cuts at the nearest paragraph break (or hard-cuts) and dispatches; the under-limit remainder keeps accumulating. Exported so the cron dispatcher obeys the same limit (see [Proactive sending](#proactive-sending-cron))
|
|
127
|
+
- Every chunk goes through one serialized send chain per responder (`sendTail`, same as Slack / Feishu) so a long reply arrives in order; `close()` returns the drain promise and `InboundBridge` keeps the reply route alive until it settles
|
|
124
128
|
- `error` → immediate flush + send a `[错误] …` message; `system` → immediate flush + send a `[系统] …` message
|
|
125
129
|
- Tool calls / tool results / thinking are dropped (detail lives in the web UI)
|
|
126
130
|
- Media: the agent emits `MEDIA: <path>` markers, which the responder extracts and turns into actual media uploads
|
|
@@ -101,6 +101,8 @@ Source: [event-processor.ts:48-97](../../../packages/server/src/ws/event-process
|
|
|
101
101
|
|
|
102
102
|
`chat:thinking` / `chat:stream` / `chat:followup` / `agent:tool_call` / `agent:tool_result` additionally carry `replay: true` when synthesized by the reattach path (never on live events) — see [Reconnect flow](#reconnect-flow) step 6.
|
|
103
103
|
|
|
104
|
+
Server-internal flags on `AgentSessionEvent` that are **not** carried into the WS frame: `stream.final` (marks the turn's wrap-up text vs. pre-tool filler — consumed by channel responders and the cli, see [session.md](session.md#message-queue-and-drain)) and `complete.batchBoundary`. The admin renders every streamed block, so neither is needed on the wire.
|
|
105
|
+
|
|
104
106
|
### Other Server → Client messages
|
|
105
107
|
|
|
106
108
|
| Type | Source | Purpose |
|
|
@@ -146,14 +146,14 @@ async function handleInbound({ registry, db, account, event, bridge, activeOverr
|
|
|
146
146
|
|
|
147
147
|
### 5. Write the event adapter
|
|
148
148
|
|
|
149
|
-
`event-adapter.ts`: buffers streamed text and sends whole messages. WeChat's flushes on either a 3500-char hard ceiling (
|
|
149
|
+
`event-adapter.ts`: buffers streamed text and sends whole messages. WeChat's flushes on either a 3500-char hard ceiling (`WECHAT_TEXT_LIMIT`, the ilink gateway rejects >16 KB) or a `complete` event; see [packages/server/src/channels/wechat/event-adapter.ts](../../../packages/server/src/channels/wechat/event-adapter.ts). Use the shared `splitText(text, limit)` from `channels/shared/chunk.ts` for the cut (paragraph boundary, then hard-cut) — all four existing responders do; don't write a private splitter. Slack has a 40k char limit but users dislike huge messages, so it splits at 35k. Serialize your sends through one promise chain per responder (see wechat's `sendTail`) and return it from `close()` so the bridge keeps the route alive until the tail has gone out.
|
|
150
150
|
|
|
151
151
|
Its send primitives take **no destination argument** — they read `bridge.getRoute(sessionId)` on each send (see step 4). A responder that captured a chat/user id at construction time is the A-M2 bug.
|
|
152
152
|
|
|
153
153
|
**What to forward, what to drop**:
|
|
154
154
|
- Drop `tool_call` / `tool_result` / `thinking` events — they're chatter the user doesn't want in IM
|
|
155
155
|
- Drop events where `event.taskId` is set (those are sub-agent events; the root agent's text is enough)
|
|
156
|
-
- Forward `stream` text (
|
|
156
|
+
- Forward `stream` text **only when `event.final` is set** — that's the turn's closing reply; the filler the model emits before a tool call ("let me check…") is chatter in IM too. `error` goes out immediately with a `[error]` prefix; `complete` flushes the remaining buffer
|
|
157
157
|
- Forward `system` if the platform can render it (e.g. Slack ephemeral messages)
|
|
158
158
|
|
|
159
159
|
### 6. Surface channel context to skills
|
|
@@ -28,7 +28,7 @@ Total effort: typically a few hours depending on how weird the upstream API is a
|
|
|
28
28
|
| `openai` | `OpenAIAgent` | `openai-agent.ts` | **Generic OpenAI-compatible**. Use for native OpenAI or any third-party OpenAI gateway (incl. Ollama / llama.cpp). User fills endpoint + model id; capabilities openly editable in the form. Forgiving about cache field names (`prompt_tokens_details.cached_tokens`, `prompt_cache_hit_tokens`, `cache_read_tokens` all read) and reasoning field names (`reasoning_content` for OpenAI o-series/DeepSeek, `reasoning` for Ollama/llama.cpp). |
|
|
29
29
|
| `anthropic` | `AnthropicAgent` | `anthropic-agent.ts` | **Generic Anthropic Messages**. Use for api.anthropic.com or any Anthropic-compatible gateway. User fills endpoint + model id. Standard `cache_control: ephemeral` with optional `ttl: '1h'`. |
|
|
30
30
|
| `mimo-token-plan-china` | `AnthropicAgent` | (reuses `anthropic-agent.ts`) | Xiaomi MiMo token plan (China) — Anthropic-compatible gateway at `token-plan-cn.xiaomimimo.com/anthropic`; no dedicated class, just a default endpoint in `model-runtime.ts` |
|
|
31
|
-
| `aws-bedrock-mantle` | `MantleAgent` | `mantle-agent.ts` | OpenAI GPT-5.6 (Sol / Terra / Luna) on Amazon Bedrock via the **OpenAI Responses API** (not Completions/Converse/InvokeModel). Vision, reasoning via `reasoning.effort` (low/med/high/xhigh/max, no budget_tokens; `max` accepted live but absent from the model's own validation list — measured ~6% more reasoning_tokens than xhigh), `text.verbosity
|
|
31
|
+
| `aws-bedrock-mantle` | `MantleAgent` | `mantle-agent.ts` | OpenAI GPT-6 Astra and GPT-5.6 (Sol / Terra / Luna) on Amazon Bedrock via the **OpenAI Responses API** (not Completions/Converse/InvokeModel). Vision, reasoning via `reasoning.effort` (low/med/high/xhigh/max, no budget_tokens; on 5.6 `max` is accepted live but absent from the model's own validation list — measured ~6% more reasoning_tokens than xhigh; on Astra it's on the official list), `text.verbosity` (low/medium/high, same on both generations). **Region split** (live-probed 2026-09-13): us-east-2 serves all 5.6 models but 404s Astra; us-west-2 serves Astra + Terra / Luna but 404s Sol — the template ships both as `endpointPresets`, default stays us-east-2, Astra agents pick us-west-2 in the form. Astra: 1,050,000 context / 128,000 max output; input beyond 272K tokens bills at 2x, so cap `context.maxTokens` in agent.yaml unless that's intended. Never surfaces reasoning summaries — `output[].reasoning.summary` stays empty even with `reasoning.summary=detailed` while `reasoning_tokens` counts up (verified live), so no thinking text in the UI is upstream behavior, not a parsing gap. |
|
|
32
32
|
|
|
33
33
|
---
|
|
34
34
|
|
|
@@ -34,7 +34,7 @@ pnpm --filter @turmind/halo-admin build # next build + copy-monaco; never a ba
|
|
|
34
34
|
|
|
35
35
|
## 2. Runtime data locations
|
|
36
36
|
|
|
37
|
-
No directory needs to be created by hand. SQLite databases are created automatically on first use: per-workspace state at `<workspace>/.halo/halo.db`, plus global queues at `~/.halo/global/evo.db` and `~/.halo/global/
|
|
37
|
+
No directory needs to be created by hand. SQLite databases are created automatically on first use: per-workspace state at `<workspace>/.halo/halo.db`, plus global queues at `~/.halo/global/evo.db`, `~/.halo/global/cron.db` and `~/.halo/global/runs.db`.
|
|
38
38
|
|
|
39
39
|
## 3. Run `halo setup`
|
|
40
40
|
|
package/bundled-docs/dev/env.md
CHANGED
|
@@ -67,7 +67,7 @@ Your deployment scripts should also prepend this line.
|
|
|
67
67
|
|
|
68
68
|
## Runtime directories
|
|
69
69
|
|
|
70
|
-
- SQLite: `<workspace>/.halo/halo.db` (per-workspace, auto-created on first use) + global queues `~/.halo/global/evo.db` and `~/.halo/global/
|
|
70
|
+
- SQLite: `<workspace>/.halo/halo.db` (per-workspace, auto-created on first use) + global queues `~/.halo/global/evo.db`, `~/.halo/global/cron.db` and `~/.halo/global/runs.db`
|
|
71
71
|
- Session files: `.halo/sessions/{agentId}/{sessionId}.json`
|
|
72
72
|
- Global config: `~/.halo/global/`
|
|
73
73
|
- Per-project config: `<workspace>/.halo/`
|
|
@@ -126,7 +126,7 @@ settings.yaml only (no env override):
|
|
|
126
126
|
- `general.server.trust_proxy` (default `false`) — trust `x-forwarded-for` for client IP resolution, scope: global (enable only behind a reverse proxy you control)
|
|
127
127
|
- `general.sandbox.hidden_dirs` (default `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs`) — bwrap tmpfs overlays, scope: global
|
|
128
128
|
- `general.sandbox.writable_dirs` (default empty) — dirs bind-mounted read-write inside the bwrap sandbox, for external CLIs that keep local state (e.g. `~/.kiro`); not applied to readonly sessions, scope: global
|
|
129
|
-
- `general.sandbox.hidden_files` (default `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron}.db` + their `-wal`/`-shm` files) — bwrap /dev/null binds, scope: global
|
|
129
|
+
- `general.sandbox.hidden_files` (default `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + their `-wal`/`-shm` files) — bwrap /dev/null binds, scope: global
|
|
130
130
|
- `general.logging.level` (default `warn`) — log level: debug | info | warn | error
|
|
131
131
|
|
|
132
132
|
`<provider-id>.secrets.*` (server-only, hard-rejected by `{{}}` substitution; declared in `models/<provider-id>.yaml` `secrets:`):
|
|
@@ -138,7 +138,7 @@ Sensitive directories and files are hidden from workspace/readonly sessions via
|
|
|
138
138
|
| Setting | Default | Method |
|
|
139
139
|
|---|---|---|
|
|
140
140
|
| `hidden_dirs` | `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs` | `--tmpfs` overlay (empty directory) |
|
|
141
|
-
| `hidden_files` | `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron}.db` + their `-wal`/`-shm` files | `--ro-bind /dev/null` (empty file) |
|
|
141
|
+
| `hidden_files` | `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + their `-wal`/`-shm` files | `--ro-bind /dev/null` (empty file) |
|
|
142
142
|
| `writable_dirs` | (empty) | `--bind` read-write — for external CLIs that keep local state (e.g. `~/.kiro`); not applied to readonly sessions |
|
|
143
143
|
|
|
144
144
|
Changes take effect immediately — `config.ts` reads settings.yaml via an mtime-watched lazy cache, so the next `shell_exec` reads the latest values. These keys are `globalOnly` in the schema — a workspace `settings.yaml` cannot override them, since they define the security boundary agents run inside.
|
|
@@ -97,6 +97,7 @@ Fan-out across multiple WeChat users is not supported — one bot, one owner.
|
|
|
97
97
|
| Two halo processes both poll the same bot | Each process gets a copy of every message, replied twice. Make sure only one server runs — see `~/.halo/global/server.pid` |
|
|
98
98
|
| Group chat doesn't trigger the bot | Expected — groups aren't supported in v1 |
|
|
99
99
|
| Sent voice message returns no reply | Check that the account has `workspace` or `full` access; readonly cannot save inbound media |
|
|
100
|
+
| Cron push fails with `ret=-2 prepare failed` while chat replies work | Two causes: the report exceeds 16 KB (halo chunks at 3500 chars, so this is rare now), or the account has no stored `context_token` for the recipient yet — the gateway wants outbound to echo the user's latest inbound token, which halo persists on every inbound message since 1.1.8. Send the bot any message once and the next cron push carries it |
|
|
100
101
|
|
|
101
102
|
## Multi-workspace / multi-bot
|
|
102
103
|
|
|
@@ -69,14 +69,14 @@ To list agents or sessions without running a prompt, use the dedicated subcomman
|
|
|
69
69
|
|
|
70
70
|
### Output
|
|
71
71
|
|
|
72
|
-
- **text format**:
|
|
72
|
+
- **text format**: stdout carries the agent's **final answer** — the closing text of the last turn, not the live stream. Text the agent emits before a tool call ("let me check…") and the wrap-ups of earlier turns in the same run (e.g. a director absorbing several sub-agent reports) are omitted; if the last turn had no closing text, its full text is printed instead. When stdout is a terminal the answer is rendered as styled markdown (headings, bold, code blocks, tables); when piped or redirected it is written as raw markdown so downstream tools get the text unchanged. Tool calls, thinking, usage — and, with `-v`, the agent's text as it streams — go to stderr.
|
|
73
73
|
|
|
74
74
|
Usage line mirrors the admin chat-panel badge format (timestamp / in / out / ctx / read / write / cache% / ttft / e2e / think / model). `ctx` is the rolled-up context size — `inputTokens + cacheRead + cacheWrite + outputTokens` — so it doesn't look misleading next to a large cache hit:
|
|
75
75
|
|
|
76
76
|
```
|
|
77
77
|
[ 17:23:02 in 0.0K out 0.1K ctx 5.8K read 5.3K write 0.3K cache 94% e2e 3.3s think medium claude-sonnet-4-6 ]
|
|
78
78
|
```
|
|
79
|
-
- **json format**: Single JSON object on stdout after completion:
|
|
79
|
+
- **json format**: Single JSON object on stdout after completion (`text` has the same final-answer semantics as text format):
|
|
80
80
|
|
|
81
81
|
```json
|
|
82
82
|
{
|