@turmind/halo 1.6.2 → 1.6.4
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/README.md +58 -67
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/989-5d7743ff56d00dd7.js +1 -0
- package/admin-out/_next/static/chunks/app/page-8881b4f87a3d5ae8.js +1 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +4 -4
- package/bin/halo.js +4 -0
- package/bundled-docs/design/architecture.md +4 -1
- package/bundled-docs/design/canvas-extensions.md +5 -5
- package/bundled-docs/design/cron.md +3 -3
- package/bundled-docs/design/observability.md +7 -5
- package/bundled-docs/design/storage.md +8 -5
- package/bundled-docs/design/wechat.md +1 -1
- package/bundled-docs/design/ws.md +1 -0
- package/bundled-docs/dev/add-model-provider.md +2 -1
- package/bundled-docs/dev/api.md +7 -3
- package/bundled-docs/dev/deploy.md +114 -26
- package/bundled-docs/dev/desktop-packaging.md +34 -0
- package/bundled-docs/dev/env.md +3 -3
- package/bundled-docs/dev/logging.md +13 -2
- package/bundled-docs/dev/tools.md +2 -2
- package/bundled-docs/guide/agents.md +2 -2
- package/bundled-docs/guide/cli.md +20 -8
- package/bundled-docs/guide/getting-started.md +13 -5
- package/bundled-docs/guide/workspace.md +2 -0
- package/bundled-docs/requirements/agent.md +1 -1
- package/bundled-docs/requirements/explorer.md +14 -2
- package/bundled-docs/requirements/settings.md +2 -2
- package/bundled-docs/requirements/skill.md +4 -4
- package/dist/index.js +2757 -1490
- package/package.json +3 -2
- package/templates/models/gemini.yaml +93 -0
- package/templates/skills/cron/SKILL.md +3 -2
- package/templates/skills/workspace/setup-tidy.md +1 -0
- package/admin-out/_next/static/chunks/989-ddba0eb72566da23.js +0 -1
- package/admin-out/_next/static/chunks/app/page-1d27029caba48956.js +0 -1
- /package/admin-out/_next/static/{VgAe62AuGEiYrF4JQ7WQe → kfp1cMc36J6hjZ06L9mm-}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{VgAe62AuGEiYrF4JQ7WQe → kfp1cMc36J6hjZ06L9mm-}/_ssgManifest.js +0 -0
package/admin-out/index.html
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<!DOCTYPE html><!--
|
|
1
|
+
<!DOCTYPE html><!--kfp1cMc36J6hjZ06L9mm_--><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-5d7743ff56d00dd7.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-8881b4f87a3d5ae8.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-5d7743ff56d00dd7.js\",\"177\",\"static/chunks/app/layout-ea161287d934ba96.js\"],\"ThemeProvider\"]\n3:I[24554,[\"989\",\"static/chunks/989-5d7743ff56d00dd7.js\",\"177\",\"static/chunks/app/layout-ea161287d934ba96.js\"],\"AppI18nProvider\"]\n4:I[63693,[],\"\"]\n5:I[74713,[],\"\"]\n6:I[5444,[],\"ClientPageRoot\"]\n7:I[88625,[\"560\",\"static/chunks/1d2d5650-d8d130c85727fdbb.js\",\"147\",\"static/chunks/147-d4200c70445aabb6.js\",\"989\",\"static/chunks/989-5d7743ff56d00dd7.js\",\"974\",\"static/chunks/app/page-8881b4f87a3d5ae8.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\":\"kfp1cMc36J6hjZ06L9mm-\",\"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>
|
package/admin-out/index.txt
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
1:"$Sreact.fragment"
|
|
2
|
-
2:I[58321,["989","static/chunks/989-
|
|
3
|
-
3:I[24554,["989","static/chunks/989-
|
|
2
|
+
2:I[58321,["989","static/chunks/989-5d7743ff56d00dd7.js","177","static/chunks/app/layout-ea161287d934ba96.js"],"ThemeProvider"]
|
|
3
|
+
3:I[24554,["989","static/chunks/989-5d7743ff56d00dd7.js","177","static/chunks/app/layout-ea161287d934ba96.js"],"AppI18nProvider"]
|
|
4
4
|
4:I[63693,[],""]
|
|
5
5
|
5:I[74713,[],""]
|
|
6
6
|
6:I[5444,[],"ClientPageRoot"]
|
|
7
|
-
7:I[
|
|
7
|
+
7:I[88625,["560","static/chunks/1d2d5650-d8d130c85727fdbb.js","147","static/chunks/147-d4200c70445aabb6.js","989","static/chunks/989-5d7743ff56d00dd7.js","974","static/chunks/app/page-8881b4f87a3d5ae8.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":"
|
|
18
|
+
0:{"P":null,"b":"kfp1cMc36J6hjZ06L9mm-","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"}]]
|
package/bin/halo.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
if (Number(process.versions.node.split('.')[0]) < 22) {
|
|
3
|
+
process.stderr.write('halo requires Node.js 22 or newer (found ' + process.version + '). Upgrade: https://nodejs.org/ (or `nvm install 22`)\n')
|
|
4
|
+
process.exit(1)
|
|
5
|
+
}
|
|
2
6
|
const emit = process.emitWarning.bind(process)
|
|
3
7
|
process.emitWarning = (warning, ...args) => {
|
|
4
8
|
const o = args[0]
|
|
@@ -179,7 +179,7 @@ File: `tools/workspace-tools.ts`. 9 tools: file_read / view_image / file_write /
|
|
|
179
179
|
File: `ws/handler.ts` (`cleanupConnection`) + `bufferDetachedNotification` in `ws/event-processor.ts`. After WS disconnect, an inline handler takes over the session tree's events and buffers structural ones for replay on reconnect. `/session new` only opens a draft tab — the previous session stays subscribed — see [design/background-dispatch.md](background-dispatch.md).
|
|
180
180
|
|
|
181
181
|
### WorkspaceWatcher — file watching
|
|
182
|
-
File: `ws/file-watcher.ts`. `@parcel/watcher` recursive native subscription (one per workspace root, shared across connections via `ws/watcher-pool.ts`) → 300ms debounce + per-path Map dedup → callback. Ignores node_modules / .git / .next / dist etc. (`IGNORED_SEGMENTS`) — passed to parcel as the bare names (top-level `ignorePaths`, string compare) plus **one** `**/{a,b,…}/**` brace glob for nested occurrences. Not one glob per segment: parcel runs every ignore regex against every inode during the initial walk, and 52 separate regexes made subscribe on a ~26k-inode repo take ~13s (one brace regex: ~0.8s, same watch set). `.halo/sessions/` and `.halo/logs/` are **not** ignored — the dedup keeps volume low, and the front-end drops `change` events for files not open in the editor, so Explorer can reflect session deletions/creations while chat streaming stays cheap.
|
|
182
|
+
File: `ws/file-watcher.ts`. `@parcel/watcher` recursive native subscription (one per workspace root, shared across connections via `ws/watcher-pool.ts`) → 300ms debounce + per-path Map dedup → callback. Ignores node_modules / .git / .next / dist etc. (`IGNORED_SEGMENTS`, which also sets folder download's default exclusions — `routes/folder-zip.ts`) — passed to parcel as the bare names (top-level `ignorePaths`, string compare) plus **one** `**/{a,b,…}/**` brace glob for nested occurrences. Not one glob per segment: parcel runs every ignore regex against every inode during the initial walk, and 52 separate regexes made subscribe on a ~26k-inode repo take ~13s (one brace regex: ~0.8s, same watch set). `.halo/sessions/` and `.halo/logs/` are **not** ignored — the dedup keeps volume low, and the front-end drops `change` events for files not open in the editor, so Explorer can reflect session deletions/creations while chat streaming stays cheap.
|
|
183
183
|
|
|
184
184
|
### TerminalManager — PTY management
|
|
185
185
|
File: `ws/terminal-manager.ts`. Spawns a shell via node-pty. On disconnect, detaches with a 50KB ring buffer; replays on reconnect.
|
|
@@ -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. `
|
|
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.
|
|
@@ -229,10 +229,10 @@ The ipynb extension (2.0.0+) runs cells on **real kernels on the Halo machine**.
|
|
|
229
229
|
- **Trust level = the built-in terminal**: kernels run as the Halo service user, unsandboxed; admin cookie only (the HTTP routes sit behind `authMiddleware`, the WS handshake checks the cookie like `/ws`; web-channel / IM / A2A tokens can't reach it); not mounted in AgentCore mode; the child env is `cleanChildEnv()`. No manifest capability gates it — `init.jupyter` is the host's signal (a new capability value would make ipynb 2.0.0 uninstallable on a 1.6.0 registry, same reasoning as `init.export`). The iframe is same-origin with the login cookie, so the extension fetches / opens the WS to `/api/jupyter/*` itself.
|
|
230
230
|
- **Settings** (`general.jupyter.*`, globalOnly, read at each spawn — no restart): `enabled` (default `true`) and `path` (default empty = `jupyter` on PATH, else the full path of the executable).
|
|
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
|
-
- **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.
|
|
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). Both spawns (the `--version` probe and the server) pass `cwd: os.homedir()` — inheriting the server's cwd left a live jupyter holding the desktop install dir open, blocking the installer; `root_dir` is unaffected. 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
|
|
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
|
|
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
|
|
@@ -259,7 +259,7 @@ Built-in skill `templates/skills/extension/` (in `BUILTIN_SKILL_IDS`; `TEMPLATE_
|
|
|
259
259
|
- An API error or "no release" on an identified platform stops with a hint (set the token, or download the zip from the releases page and `install <path>`). **Never a git fallback**: a git checkout of a build-step extension lacks its build output and would install broken.
|
|
260
260
|
- Anything else (ssh, `ssh://`, `file://`, a local path, an https host that answered neither probe) → git mode: the highest `<id>-v<x.y.z>` tag by semver (prereleases skipped), shallow clone, `extensions/<id>/` minus the build inputs `pack.mjs` leaves out of a zip (`node_modules/`, `src/`, `package*.json`). Refused ("needs a build step") when the package has `fetch-deps.sh` / `build.sh` — a presence check, and all four current halo-hub extensions carry one, so a plain-git mirror of halo-hub installs none of them by id today.
|
|
261
261
|
|
|
262
|
-
The hub repo keeps one directory per extension (`glb` / `ipynb` commit their vendored deps, `fetch-deps.sh` only refreshes them; `drawio` / `excalidraw` gitignore their build output and commit only the inputs), `scripts/pack.mjs extensions/<id>` → `dist/<id>-<version>.zip` with contents at the zip root (maintenance scripts and build inputs excluded), and one release per tag `<id>-v<version>` carrying exactly one zip (a second one would be picked by accident). Skills and workspaces in the same repo are tagged `skill-<id>-v<ver>` / `ws-<id>-v<ver>`; nothing installs them yet. Catalog today (only `glb-v1.0.0` is released so far):
|
|
262
|
+
The hub repo keeps one directory per extension (`glb` / `ipynb` commit their vendored deps, `fetch-deps.sh` only refreshes them; `drawio` / `excalidraw` gitignore their build output and commit only the inputs), `scripts/pack.mjs extensions/<id>` → `dist/<id>-<version>.zip` with contents at the zip root (maintenance scripts and build inputs excluded; `pack.mjs` hand-mirrors the `registry.ts` manifest rules, and `packages/server/test/extension-manifest-rules-drift.test.ts` pins a hash of those rules and fails with a pointer to the hub copy when they change), and one release per tag `<id>-v<version>` carrying exactly one zip (a second one would be picked by accident). Skills and workspaces in the same repo are tagged `skill-<id>-v<ver>` / `ws-<id>-v<ver>`; nothing installs them yet. Catalog today (only `glb-v1.0.0` is released so far):
|
|
263
263
|
|
|
264
264
|
- `glb` 1.1.0 — `.glb .gltf .obj .stl`, `default`, read-only (`<model-viewer>` 4.3.1 offline bundle with Draco/Basis decoders; `.obj` / `.stl` converted in memory to GLB via three.js; `.gltf` must be self-contained — external `.bin` / textures → `error` frame; no `.mtl`).
|
|
265
265
|
- `ipynb` 2.0.0 — `.ipynb`, `default`, `save`: notebook editor (markdown, KaTeX math, highlighted code, outputs incl. images / HTML tables; no widgets / JS outputs) that runs cells through the Jupyter proxy above when the host sends `init.jupyter` (Halo ≥ 1.6.1); older hosts edit and save without run controls.
|
|
@@ -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
|
|
|
@@ -120,7 +120,7 @@ interface CronChannelDispatcher {
|
|
|
120
120
|
|
|
121
121
|
## Retention
|
|
122
122
|
|
|
123
|
-
- **Runs**: keep newest `RUNS_PER_JOB_KEEP` (100) rows per job; older pruned on each new run
|
|
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.
|
|
124
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.
|
|
125
125
|
|
|
126
126
|
## Key files
|
|
@@ -130,7 +130,7 @@ interface CronChannelDispatcher {
|
|
|
130
130
|
- `packages/server/src/db/cron-db.ts` — `cron_jobs` + `cron_runs` schema, `CRON_MIGRATIONS`, `getCronDb`.
|
|
131
131
|
- `packages/server/src/agents/session-manager.ts` — `forgetExternalWrite` (drop the stale in-memory copy of a session a cli child just wrote).
|
|
132
132
|
- `packages/server/src/channels/<ch>/cron-dispatcher.ts` — per-channel `dispatch` + `listTargets`.
|
|
133
|
-
- `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>`.
|
|
134
134
|
|
|
135
135
|
## Scope
|
|
136
136
|
|
|
@@ -8,12 +8,12 @@ A vendor-neutral observability layer built on the official OpenTelemetry JS SDK.
|
|
|
8
8
|
|
|
9
9
|
| key | type | default | description |
|
|
10
10
|
|---|---|---|---|
|
|
11
|
-
| `general.observability.endpoint` | string | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`). Traces, metrics and logs all exported over OTLP http/protobuf — `http://` or `https://`, on the collector's OTLP/HTTP port (4318 by default); gRPC (4317) not supported. Empty = off.
|
|
12
|
-
| `general.observability.service_name` | string | `halo` | OTel resource `service.name`.
|
|
13
|
-
| `general.observability.headers` | string, **secret** | `''` | Extra headers for every OTLP request, comma-separated `k=v` (e.g. `authorization=Bearer …`).
|
|
14
|
-
| `general.observability.capture_content` | boolean | `false` | Put prompt / completion / tool argument+result text on spans (`gen_ai.*.messages` etc.). Off = metadata only (model, tokens, latency, tool names).
|
|
11
|
+
| `general.observability.endpoint` | string | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`). Traces, metrics and logs all exported over OTLP http/protobuf — `http://` or `https://`, on the collector's OTLP/HTTP port (4318 by default); gRPC (4317) not supported. Empty = off. Server: restart required; cron / evolution runs: next run. |
|
|
12
|
+
| `general.observability.service_name` | string | `halo` | OTel resource `service.name`. Server: restart required; cron / evolution runs: next run. |
|
|
13
|
+
| `general.observability.headers` | string, **secret** | `''` | Extra headers for every OTLP request, comma-separated `k=v` (e.g. `authorization=Bearer …`). Server: restart required; cron / evolution runs: next run. |
|
|
14
|
+
| `general.observability.capture_content` | boolean | `false` | Put prompt / completion / tool argument+result text on spans (`gen_ai.*.messages` etc.). Off = metadata only (model, tokens, latency, tool names). Server: restart required; cron / evolution runs: next run. |
|
|
15
15
|
|
|
16
|
-
All four keys are `globalOnly
|
|
16
|
+
All four keys are `globalOnly`. The server's own exporter switches only on restart — `otel.ts` reads them once at boot, before anything else touches the OTel API. A `halo cli` / `halo tui` process reads them the same way at its own start, so a change reaches the next run — including the cron and evolution children the server spawns, which don't inherit the server's boot-time values (see the child-env rule below). A changed or cleared endpoint therefore reaches their next run without a server restart.
|
|
17
17
|
|
|
18
18
|
**Which process exported.** Every process shares `service.name`, so the resource also carries `halo.process` = `server` \| `cli` \| `tui` (`HaloProcess` in `otel.ts`; `initObservability()` defaults to `server`). Cron and evolution children are `halo cli` runs, so they report `cli`; their `session.id` (`cron-<jobId>`, or the `__evo_agent__`-style agent name) tells them apart from a hand-run cli. The startup log line names it: `[Observability] OTLP export → … (service.name=…, halo.process=cli, …)`.
|
|
19
19
|
|
|
@@ -26,6 +26,8 @@ All four keys are `globalOnly` and take effect on server restart — `otel.ts` r
|
|
|
26
26
|
|
|
27
27
|
No `OTEL_EXPORTER_OTLP_ENDPOINT` after the mapping → `enabled=false`, `initObservability()` returns without registering anything.
|
|
28
28
|
|
|
29
|
+
**Child env.** Every key the mapping actually set (one that was unset before) is recorded in `settingsOtelEnvKeys` (`child-env.ts`), and `cleanChildEnv()` strips those keys from every child it builds. Inherited, the server's boot-time values would win a cron / evolution `halo cli` child's own `??=`, so a changed endpoint would never arrive and a cleared one would keep exporting until a server restart. Operator-exported `OTEL_*` keys aren't recorded and still pass through ("operator wins" holds in children too). Side effect: terminals, sandbox shells and the Jupyter server — also built from `cleanChildEnv()` — no longer inherit Halo's settings-derived collector config.
|
|
30
|
+
|
|
29
31
|
**External-SDK detection.** If an external SDK is already registered (e.g. via `node --require` of a vendor distro) — detected by comparing `ProxyTracerProvider.getDelegate()` against a fresh proxy's `NoopTracerProvider` delegate — halo logs `[Observability] external OpenTelemetry SDK already registered — using it, skipping built-in exporters` and skips its own exporters. The `??=` env mapping above still applies first, so the external SDK picks up halo's settings unless it already set its own.
|
|
30
32
|
|
|
31
33
|
**TLS / HTTPS.** `https://` endpoints work out of the box against CA-signed certs — the exporter picks `node:http` vs `node:https` by URL scheme. Self-signed certs / mTLS go through the standard exporter env vars `OTEL_EXPORTER_OTLP_CERTIFICATE` / `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE` / `OTEL_EXPORTER_OTLP_CLIENT_KEY` (read by the exporter itself, not by halo code). Verified: a self-signed sink is rejected with `DEPTH_ZERO_SELF_SIGNED_CERT` unless the cert is trusted — no silent bypass.
|
|
@@ -39,9 +39,12 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
39
39
|
│ ├── settings.yaml # Global settings (API keys, credentials)
|
|
40
40
|
│ ├── config.yaml # System config (admin password, server settings)
|
|
41
41
|
│ ├── a2a-remotes.yaml # A2A remote list (server-wide, full-only; see design/a2a.md)
|
|
42
|
+
│ ├── server.env # Env snapshot for the systemd user unit (600), rewritten by `halo server start -d` / `restart`
|
|
42
43
|
│ └── channels/
|
|
43
44
|
│ └── channels.db # Channel accounts DB
|
|
44
45
|
|
|
46
|
+
~/.config/systemd/user/halo.service # Outside ~/.halo: the user unit `halo server start -d` writes on Linux with systemd
|
|
47
|
+
|
|
45
48
|
<workspace>/.halo/ # Workspace-level (overrides same-name global configs)
|
|
46
49
|
├── USER.md # Optional, overrides global
|
|
47
50
|
├── INSTRUCTIONS.md # Project-level instructions (overrides global INSTRUCTIONS.md)
|
|
@@ -413,16 +416,16 @@ A value of the form `<<ENV_NAME>>` is replaced at read time with `process.env.EN
|
|
|
413
416
|
| `general.logging.level` | warn | `debug` / `info` / `warn` / `error`. Restart required |
|
|
414
417
|
| `general.a2a.public_url` | `''` | Global-only. Origin peers use to reach this server for A2A (no trailing slash): the agent card's interface URL and the default push-webhook base for `a2a_send`. Empty = derived from the request host (no outbound dispatch without it or a remote's `push_base`). Env `HALO_A2A_PUBLIC_URL` overrides. See [a2a.md](a2a.md) |
|
|
415
418
|
| `general.a2a.url_allowlist` | `100.64.0.0/10,*.ts.net` | Global-only. CIDRs / host patterns A2A egress may reach besides public https (push webhooks, remote cards and RPC, inbound image URLs). Private / loopback only when listed; plain http only to listed hosts / ranges; link-local always refused. See [a2a.md](a2a.md#push-delivery-and-url-policy) |
|
|
416
|
-
| `general.observability.endpoint` | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`); empty = off. Restart required. See [observability.md](observability.md) |
|
|
417
|
-
| `general.observability.service_name` | `halo` | OTel resource `service.name`. Restart required |
|
|
418
|
-
| `general.observability.headers` | `''` | **Secret.** Extra OTLP request headers, comma-separated `k=v`. Restart required |
|
|
419
|
-
| `general.observability.capture_content` | `false` | Put prompt/completion/tool text on spans. Restart required |
|
|
419
|
+
| `general.observability.endpoint` | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`); empty = off. Restart required (server); cron / evolution runs use a new value on their next run. See [observability.md](observability.md) |
|
|
420
|
+
| `general.observability.service_name` | `halo` | OTel resource `service.name`. Restart required (server); cron / evolution: next run |
|
|
421
|
+
| `general.observability.headers` | `''` | **Secret.** Extra OTLP request headers, comma-separated `k=v`. Restart required (server); cron / evolution: next run |
|
|
422
|
+
| `general.observability.capture_content` | `false` | Put prompt/completion/tool text on spans. Restart required (server); cron / evolution: next run |
|
|
420
423
|
|
|
421
424
|
Schema source: [packages/server/src/settings-schema.ts](../../../packages/server/src/settings-schema.ts) `generalSection()`.
|
|
422
425
|
|
|
423
426
|
### Caching
|
|
424
427
|
|
|
425
|
-
`config.ts` reads settings.yaml lazily with mtime-watching: every read stats the file and reparses if mtime changed. UI saves bump mtime, so the server picks up new values on the next read **without a restart** — except the keys marked "Restart required" above plus `general.agent.max_retries`, which `config.ts` reads once into object literals at boot (observability: `initObservability()`); their schema fields carry `restartRequired: true` and the Settings page shows a restart notice after saving one.
|
|
428
|
+
`config.ts` reads settings.yaml lazily with mtime-watching: every read stats the file and reparses if mtime changed. UI saves bump mtime, so the server picks up new values on the next read **without a restart** — except the keys marked "Restart required" above plus `general.agent.max_retries`, which `config.ts` reads once into object literals at boot (observability: `initObservability()`, mapped onto `OTEL_*` env; the cron / evolution `halo cli` children don't inherit those settings-derived vars — `cleanChildEnv()` strips them — so each re-reads settings at its own start); their schema fields carry `restartRequired: true` and the Settings page shows a restart notice after saving one.
|
|
426
429
|
|
|
427
430
|
### Orphans
|
|
428
431
|
|
|
@@ -96,7 +96,7 @@ loop {
|
|
|
96
96
|
}
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
> **单实例依赖**:长轮询循环跟 HTTP server 解耦 —— 即便 `:9527` 没绑上,只要进程活着,`runAccountLoop` 就继续拉消息。如果有孤儿 server 进程残留,每个进程都会独立收到同一条消息并 fan out 到不同 session(每个进程内存里的 `activeOverrides` 是独立的,`findActiveSessionId` 可能返回不同结果)。因此 `src/index.ts` 在启动时要抢 `~/.halo/global/server.pid` 单实例锁,详见 [dev/deploy.md](../dev/deploy.md#
|
|
99
|
+
> **单实例依赖**:长轮询循环跟 HTTP server 解耦 —— 即便 `:9527` 没绑上,只要进程活着,`runAccountLoop` 就继续拉消息。如果有孤儿 server 进程残留,每个进程都会独立收到同一条消息并 fan out 到不同 session(每个进程内存里的 `activeOverrides` 是独立的,`findActiveSessionId` 可能返回不同结果)。因此 `src/index.ts` 在启动时要抢 `~/.halo/global/server.pid` 单实例锁,详见 [dev/deploy.md](../dev/deploy.md#5-start-the-server)。
|
|
100
100
|
|
|
101
101
|
`handleInbound(account, msg)` — WeChat-specific head, then the shared tail:
|
|
102
102
|
1. Extract `from_user_id` / text + media items / `context_token`
|
|
@@ -143,6 +143,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
|
|
|
143
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). |
|
|
144
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). |
|
|
145
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. |
|
|
146
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). |
|
|
147
148
|
|
|
148
149
|
### `error` frames: optional `code`
|
|
@@ -18,7 +18,7 @@ Total effort: typically a few hours depending on how weird the upstream API is a
|
|
|
18
18
|
|
|
19
19
|
### Existing providers
|
|
20
20
|
|
|
21
|
-
Runtime names (`runtime:` value → class): `bedrock-invoke` → `BedrockAgent`, `bedrock-mantle` → `MantleAgent` (OpenAI Responses API), `anthropic-messages` → `AnthropicAgent`, `openai-chat` → `OpenAIAgent` (Chat Completions); the vendor subclasses keep their vendor name (`kimi`, `deepseek`, `minimax`, `qwen`, `hunyuan`, `doubao`, `zhipu`). No implementation class reads the provider id — the endpoint (required in `agent.yaml`) carries everything host-specific.
|
|
21
|
+
Runtime names (`runtime:` value → class): `bedrock-invoke` → `BedrockAgent`, `bedrock-mantle` → `MantleAgent` (OpenAI Responses API), `anthropic-messages` → `AnthropicAgent`, `openai-chat` → `OpenAIAgent` (Chat Completions); the vendor subclasses keep their vendor name (`kimi`, `deepseek`, `minimax`, `qwen`, `hunyuan`, `doubao`, `zhipu`, `gemini`). No implementation class reads the provider id — the endpoint (required in `agent.yaml`) carries everything host-specific.
|
|
22
22
|
|
|
23
23
|
| Provider ID | `runtime` | Class | File | Notes |
|
|
24
24
|
|---|---|---|---|---|
|
|
@@ -30,6 +30,7 @@ Runtime names (`runtime:` value → class): `bedrock-invoke` → `BedrockAgent`,
|
|
|
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). |
|
|
33
|
+
| `gemini` | `gemini` | `GeminiAgent` | `gemini-agent.ts` | Google Gemini 3.x on the Gemini API's OpenAI-compatible surface (`generativelanguage.googleapis.com/v1beta/openai`, Bearer key), streaming via `fetchChatCompletionStream`. **Thought signatures**: the first tool call of a reply carries `extra_content.google.thought_signature`, and a follow-up that replays a current-turn call without it 400s. The runtime takes the signatures from the raw frames (the `onChunk` hook) and re-attaches them by tool-call id. A call it never saw (rebuilt instance) gets Google's placeholder `skip_thought_signature_validator`. Tool-call replies end with `finish_reason: "stop"`, so they're folded as `tool_calls`. Thinking can't be turned off on Gemini 3. The level goes as `extra_body.google.thinking_config.thinking_level` with `include_thoughts` (this 400s next to `reasoning_effort`). The summary streams inline as `<thought>…</thought>` at the start of `content` and is split out into thinking. Levels per model: 3.8-flash low / medium / high, 3.5-flash-lite minimal…high, xhigh / max invalid everywhere. Usage: `completion_tokens` excludes thought tokens, so output = `total_tokens − prompt_tokens`. Implicit caching via `prompt_tokens_details.cached_tokens` (included in `prompt_tokens`, subtracted). Vision via `image_url` data URLs (probed 2026-10-12) |
|
|
33
34
|
| `openai` | `openai-chat` | `OpenAIAgent` | `openai-agent.ts` | **Generic OpenAI-compatible**. Use for native OpenAI or any third-party OpenAI gateway (incl. Ollama / llama.cpp). Ships a short Bedrock Mantle list callable with a Bedrock API key (`openai.gpt-5.5` default, `openai.gpt-5.4`; `defaultEndpoint` Mantle us-east-1 `/openai/v1`, presets us-east-1 / us-west-2 / api.openai.com — GPT-6.x / 5.6 reject `max_tokens`, so they stay on the Responses-API providers) plus the legacy `gpt-4o` placeholder (official API only); any other endpoint + model id can be typed, and capabilities for unlisted ids are openly editable in the form. Streaming via `fetchChatCompletionStream` — always sends `stream: true` + `stream_options: { include_usage: true }` (OpenAI's own field; Ollama ≥ 0.3 / vLLM / LM Studio accept it too). A gateway that rejects unknown fields would 400 — accepted trade-off, no non-streaming fallback. Forgiving about cache field names (`cachedPromptTokens`: first value > 0 of `prompt_tokens_details.cached_tokens`, top-level `cached_tokens`, `prompt_cache_hit_tokens`, `cache_read_tokens`) and reasoning field names (`reasoning_content` for OpenAI o-series/DeepSeek, `reasoning` for Ollama/llama.cpp). |
|
|
34
35
|
| `anthropic` | `anthropic-messages` | `AnthropicAgent` | `anthropic-agent.ts` | **Generic Anthropic Messages**. Use for api.anthropic.com or any Anthropic-compatible gateway. Ships a short Bedrock Mantle list callable with a Bedrock API key as `x-api-key` (`anthropic.claude-opus-5-5` default, `anthropic.claude-haiku-4-5`; `defaultEndpoint` Mantle us-east-1 `/anthropic`, presets us-east-1 / us-west-2 / api.anthropic.com) plus the legacy `claude-sonnet-4-6` placeholder (official API only); any other endpoint + model id can be typed. Streaming via `fetchAnthropicStream` (the gateway must support `stream: true` SSE — the reference Anthropic behaviour). Standard `cache_control: ephemeral` with optional `ttl: '1h'`. |
|
|
35
36
|
| `mimo-token-plan-china` | `anthropic-messages` | `AnthropicAgent` | (reuses `anthropic-agent.ts`) | Xiaomi MiMo (UI name "Xiaomi MiMo"; the id keeps its historical name so existing configs don't break) — Anthropic-compatible gateway, streams like `anthropic` (gateway built for Claude Code, which always streams; not live-probed); no dedicated class, just `runtime: anthropic-messages` in its yaml (`defaultEndpoint` `token-plan-cn.xiaomimimo.com/anthropic`). The key type picks the endpoint: Token Plan `tp-`/`ttp-` keys only work on `token-plan-{cn,sgp,ams}.xiaomimimo.com/anthropic`, pay-as-you-go `sk-` keys only on `api.xiaomimimo.com/anthropic` — all four ship as `endpointPresets` (pay-as-you-go live-probed 2026-09-24; sgp / ams from the docs only). V2.6 (`mimo-v2.6-pro` / `-flash`, both vision, 1M / 131072) is current; `mimo-v2.5(-pro)` kept in the template until the official 2026-10-21 deprecation, then drop |
|
package/bundled-docs/dev/api.md
CHANGED
|
@@ -36,6 +36,7 @@ File: `packages/server/src/routes/files.ts`
|
|
|
36
36
|
| GET | `/api/files?path=&projectId=` | Read file content (max 10 MB) |
|
|
37
37
|
| GET | `/api/files/stat?path=&projectId=` | Lightweight mtime + size |
|
|
38
38
|
| GET | `/api/files/download?path=&projectId=&inline=` | Download or inline-preview — streams the file (no full read into memory), supports `Range` (206 Partial Content) so `<video>`/`<audio>` can seek + partial-load; aborts the read if the client disconnects. Missing file → 404 `{error:'File not found'}` |
|
|
39
|
+
| GET | `/api/files/download-folder?path=&projectId=[&include=excluded][&estimate=1]` | Folder → one zip, built by yazl while it streams (zip64, UTF-8 names; already-compressed extensions stored, the rest deflated; one file open at a time, paced by backpressure; never staged on disk or in memory). `path` must be a directory inside the project, `''` = workspace root; it may itself be a folder symlink inside the workspace (resolved, then checked like any target); no `/tmp` exception. `Content-Disposition: attachment` with RFC 5987 `filename*` = `<folder name>.zip` (root → workspace dir name); no `Content-Length`, no Range. **Excluded by default**: any *folder* whose name is in `IGNORED_SEGMENTS` (`ws/file-watcher.ts`); `include=excluded` brings them back. **Always excluded**: `isHiddenHostPath` / `isHiddenWorkspacePath` (also against a nested `.halo/`), plus common credential stores (~/.kube, cloud CLI configs, shell histories — a zip-only list in `folder-zip.ts`, matched relative to the home dir), `.halo/settings.yaml`, `.env` / `.env.*`. Names a zip can't hold — any containing `\`, or a top-level `X:` prefix — are skipped as `unreadable`. Directory symlinks never followed; file symlinks only when the realpath stays inside the workspace and passes those checks. `estimate=1` → `{files, bytes, excluded: {<reason>: n}, ignoredNames: {<folder>: n}}` with reasons `ignored` / `blocked` / `symlink` / `unreadable`, same rules, no content read. More than 200,000 files or a walk over 10 s → **413** `{error}` (both modes, before any header). A blocked target → 403. Anything left out → `_halo-excluded.txt` at the zip root (grouped by reason, a pruned dir listed once, 1000 paths per reason + a count). Mid-stream (status already sent): a file that fails to open is skipped, a read error partway through truncates that entry — both are listed there. A client disconnect stops the walk and destroys the zip stream |
|
|
39
40
|
| PUT | `/api/files` | Save file (body: `{path, content, projectId}`) |
|
|
40
41
|
| PUT | `/api/files/raw?path=&projectId=[&expectMtime=][&create=1][&append=1][&root=]` | Replace an **existing** file's bytes with the raw request body (binary-safe; `PUT /api/files` is utf-8 text only). Used by canvas preview extensions' save path. 404 if the file doesn't exist (save never creates) unless `create=1`; 400 for a directory. `expectMtime` is the `modifiedAt` the client loaded from `/api/files/stat` (float ms — compared after `Math.round`): when the on-disk mtime differs → **409** `{error:'conflict', mtime, size}` so the client can offer overwrite (retry without / with the new `expectMtime`). `create=1` creates a missing file (parent dirs `mkdir -p`); `append=1` appends instead of overwriting (`expectMtime` ignored; with `create=1` a missing file is created first) — the bundle extensions' `fs` write / append. `root=<dir>` (workspace-relative; the bundle host always sends its bundle path): `<dir>` must be an existing directory and `path` strictly inside it, else **404** `{error:'Bundle root not found'}` with nothing created. Success → `{ok, path, mtime, size}` |
|
|
41
42
|
| POST | `/api/files/new` | Create empty file |
|
|
@@ -46,7 +47,7 @@ File: `packages/server/src/routes/files.ts`
|
|
|
46
47
|
|
|
47
48
|
**Inline Content-Type** for images comes from core's shared extension→MIME table (`imageMimeFromExt`, `packages/core/src/media/mime.ts`) on both `/api/files/download?inline=1` and `/api/web/file`; each route keeps only its own non-image entries (pdf / html / video / audio) and falls back to `application/octet-stream`. That table is why `.bmp` now serves as `image/bmp` on both — the two routes used to carry their own copies and `/api/web/file` returned `application/octet-stream` for it. `.svg` / `.ico` / `.avif` get a real image Content-Type when served, but are deliberately not "photos" (they're outside `IMAGE_EXTS`) so they never reach a vision model or a channel's sendPhoto.
|
|
48
49
|
|
|
49
|
-
All file operations validate the path stays inside the project root (prevents path traversal). The check (`validatePath` in `routes/workspace-path.ts`, shared with `data-preview.ts` and `git.ts`) realpaths both sides — longest existing ancestor plus the re-appended tail — so a symlink inside the workspace pointing outside it is rejected too, not just lexical `../`. One exception: `GET /api/files/download` also accepts absolute paths under `/tmp/` so that agent-produced working files (e.g. Playwright screenshots) can be inline-previewed from chat media chips.
|
|
50
|
+
All file operations validate the path stays inside the project root (prevents path traversal). The check (`validatePath` in `routes/workspace-path.ts`, shared with `data-preview.ts` and `git.ts`) realpaths both sides — longest existing ancestor plus the re-appended tail — so a symlink inside the workspace pointing outside it is rejected too, not just lexical `../`. One exception: `GET /api/files/download` (not `download-folder`) also accepts absolute paths under `/tmp/` so that agent-produced working files (e.g. Playwright screenshots) can be inline-previewed from chat media chips.
|
|
50
51
|
|
|
51
52
|
### Filesystem browse (not project-scoped — for the workspace picker)
|
|
52
53
|
|
|
@@ -100,7 +101,7 @@ Installed extensions are directories under `~/.halo/global/extensions/<id>/` wit
|
|
|
100
101
|
| 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
102
|
| POST | `/api/jupyter/kernels/:id/interrupt` · `…/restart` | cookie | `{ ok: true }`; 404 unless `:id` is a kernel Halo started (and jupyter still has) |
|
|
102
103
|
| DELETE | `/api/jupyter/kernels/:id` | cookie | Shut the kernel down → `{ ok: true }`; 404 for an unknown id |
|
|
103
|
-
| WS | `/api/jupyter/channels?kernel=<id
|
|
104
|
+
| 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
105
|
|
|
105
106
|
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
107
|
|
|
@@ -217,7 +218,7 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
|
|
|
217
218
|
| 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
219
|
| 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
220
|
| 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}` |
|
|
221
|
+
| 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
222
|
| POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget). Returns `{ok}` |
|
|
222
223
|
| POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
|
|
223
224
|
|
|
@@ -434,6 +435,8 @@ File: `packages/server/src/routes/skills.ts`
|
|
|
434
435
|
| DELETE | `/api/skills/:id?scope=&projectId=` | Delete skill directory + settings entry. `scope=extension` → 403 (read-only) |
|
|
435
436
|
| 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
437
|
|
|
438
|
+
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).
|
|
439
|
+
|
|
437
440
|
## Commands
|
|
438
441
|
|
|
439
442
|
File: `packages/server/src/routes/commands.ts`
|
|
@@ -991,5 +994,6 @@ It does not remove session files. The full delete (rows + JSON + archive segment
|
|
|
991
994
|
| `chat:queued` | `reason: 'compact'`, `message`, `sessionId` | Message queued behind a compact |
|
|
992
995
|
| `chat:ack` | `clientMsgId` | Chat appended to the session log (or re-acked on resend) |
|
|
993
996
|
| `listener:released` | `sessionId` | Event listener reclaimed — re-send `subscribe` (see above) |
|
|
997
|
+
| `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
998
|
|
|
995
999
|
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).
|