@turmind/halo 1.6.4 → 1.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +18 -9
  2. package/admin-out/404.html +1 -1
  3. package/admin-out/_next/static/chunks/87-6a2716c98a819c4e.js +1 -0
  4. package/admin-out/_next/static/chunks/989-066a874961bd519f.js +1 -0
  5. package/admin-out/_next/static/chunks/app/page-812bcbb46db628b6.js +1 -0
  6. package/admin-out/_next/static/css/e890aa0d13e94290.css +1 -0
  7. package/admin-out/index.html +1 -1
  8. package/admin-out/index.txt +5 -5
  9. package/bundled-docs/design/a2a.md +1 -1
  10. package/bundled-docs/design/canvas-extensions.md +38 -7
  11. package/bundled-docs/design/cron.md +13 -5
  12. package/bundled-docs/design/feishu.md +2 -0
  13. package/bundled-docs/design/halo-city.md +18 -149
  14. package/bundled-docs/design/slack.md +2 -2
  15. package/bundled-docs/design/storage.md +6 -2
  16. package/bundled-docs/design/stt-local.md +106 -0
  17. package/bundled-docs/design/web.md +1 -1
  18. package/bundled-docs/design/wecom.md +1 -1
  19. package/bundled-docs/dev/add-model-provider.md +1 -1
  20. package/bundled-docs/dev/api.md +13 -10
  21. package/bundled-docs/dev/desktop-packaging.md +37 -9
  22. package/bundled-docs/dev/env.md +1 -1
  23. package/bundled-docs/requirements/chat.md +8 -0
  24. package/bundled-docs/requirements/workspace.md +4 -0
  25. package/dist/index.js +1509 -604
  26. package/package.json +2 -1
  27. package/templates/models/mimo-token-plan-china.yaml +3 -55
  28. package/templates/skills/cron/SKILL.md +2 -1
  29. package/templates/skills/cron/manage-cron.py +2 -1
  30. package/admin-out/_next/static/chunks/147-d4200c70445aabb6.js +0 -1
  31. package/admin-out/_next/static/chunks/989-5d7743ff56d00dd7.js +0 -1
  32. package/admin-out/_next/static/chunks/app/page-8881b4f87a3d5ae8.js +0 -1
  33. package/admin-out/_next/static/css/3b4dcd0ac6b8eae8.css +0 -1
  34. /package/admin-out/_next/static/{kfp1cMc36J6hjZ06L9mm- → Yc05ORrVk5IE9P1Gd8Ojh}/_buildManifest.js +0 -0
  35. /package/admin-out/_next/static/{kfp1cMc36J6hjZ06L9mm- → Yc05ORrVk5IE9P1Gd8Ojh}/_ssgManifest.js +0 -0
@@ -1,130 +1,28 @@
1
1
  # Halo City · 像素街区可视化
2
2
 
3
- Halo 服务器运行态的**街景式**像素可视化:侧视、剖面的像素城,实时天色下,
4
- 每个 **workspace 是一栋楼**,每个 **agent session 是一只 Q 版动物市民**,爬真楼梯
5
- 到真工位上班。点任何人打开他的**真实会话日志**(服务端流式拉取,和 admin 面板同源)。
3
+ Halo 服务器运行态的**街景式**像素可视化:每个 **workspace 是一栋楼**,每个
4
+ **agent session 是一只 Q 版动物市民**,点任何人打开他的真实会话日志。前端从
5
+ 1.6.5 起迁到 halo-hub,作为扩展 `halo-city` 安装(装好后 admin 活动栏多一个
6
+ 入口);本仓库只留数据接口 `packages/server/src/routes/halo-city.ts`。
6
7
 
7
- 只读、零构建、零贴图素材(每个像素都是代码画的)。网络流量:一个
8
- `GET /api/show/state` 轮询,外加面板打开时的 `GET /api/show/session`。
9
-
10
- > 接口契约见 [dev/api.md](../dev/api.md#show-world-snapshot)(`/api/show/state`、
11
- > `/api/show/session` 的字段与鉴权)。本文只讲前端可视化设计。
12
-
13
- ## 街区结构
14
-
15
- ```
16
- ☀/☾ parallax skyline · clouds · stars ──────────────────────────
17
- ┌─[roof: water tank / AC / antenna / billboard]─┐
18
- │ W3 desks ×3 · skill station · flavor prop │
19
- ├───────────────────────────────────────────────┤ ┌──────────┐
20
- │ C 公共层: 冰箱·水吧·书架·沙发·街机/鱼缸/猫 ─[阳台]│ │ next │
21
- ├───────────────────────────────────────────────┤ │ building │
22
- │ W2 / W1 work floors … │ │ │
23
- ├───────────────────────────────────────────────┤ │ │
24
- │ 大堂: 前台 · 咖啡机 · 沙发 · 大门 │ │ │
25
- └─⌐stairs──────────────────────────────awning───┘ └──────────┘
26
- ──┴── sidewalk · 餐车 · 野餐桌 · 篮筐 · 单车架 · 路灯 ──┴───────────
27
- road (楼距加宽:巷子就是楼下放风的地方)
28
- ```
29
-
30
- | 看到的 | 意思 |
31
- |---|---|
32
- | 🏬 一栋楼 | 一个 workspace。**每 3 个工作层插 1 个公共层**(楼够高时),楼层数**贴合当前 root 会话数**(每层对应一个真实会话,顶上不留空层;增减都跟,但保留 1 层缓冲——会话数在两次轮询间 ±1 抖动不会让楼顶反复抽搐);砖材/霓虹竖牌/楼顶装置由 id hash 决定 |
33
- | 🐾 动物市民 | 一个 agent session。**制服**(衬衫+条纹)认 agent 名 → 在哪都认得出"researcher";**物种/毛色/花纹/配件**(10 物种 × 多毛色 × 眼镜/耳机/斑纹)认 session id → 同 agent 并发的 session 是穿同款衬衫的不同动物。会眨眼,说话动嘴,狗走路摇尾巴 |
34
- | 🪜 楼梯间 | 大楼左侧,真的逐层爬上爬下(没有传送) |
35
- | 💺 工作层 | 每层 3 张工位(按 agent 名固定)+ 1 个技能站 + 随机小设施(盆栽/小书架/白板/售货机);干活时屏幕滚代码 |
36
- | 🍵 公共层 | 冰箱(偶尔开门漏光)、水吧(水壶冒汽)、书报架、沙发,外加街机/鱼缸/猫爬架三选一;右侧伸出**阳台**(烟灰桶+盆栽)——抽烟点 |
37
- | 🚬 习惯 | 每个 session 由 id hash 派生 **3 个固定偏好**(抽烟/咖啡/泡茶/翻冰箱/看书/街机/看鱼/撸猫/打电话/下楼遛弯/沙发/工位打游戏/工位刷手机),摸鱼时偏好权重 ~4×——"这个会话就爱在阳台抽烟"是稳定人设,不是随机骰子(工位打游戏/工位刷手机不挪窝,就在自己座位上摸鱼;面板文案里"工位刷手机"沿用了"打电话"那条文案) |
38
- | 🧭 就近原则 | 市民**生在自己的归属层**(自己的工位层;子代理不论嵌套多深都解析到所在会话树的**根会话**工位层,不再掉进大堂)。休息活动以**归属层**为锚点找最近的公共层,不会为了倒杯茶爬通天,**也不会随时间往低层漂**——12 楼的人永远回 F11 休息,不会一路沉到大堂。街机/鱼缸/猫爬架这类稀有设施只在归属层 **±4 层**的漫游带内找最近一个,带外直接放弃改走最近的休息层;`下楼遛弯` 同样按漫游带算——归属层在 F3 及以下才真下街,F4+ 改在带内最近的休息层放风 |
39
- | 📌 稳定工位 | 每个**根会话**的工位楼层只在**第一次出现**时分配一次(选最低空位),离开释放、回来复用——不会因为 `/api/show/state` 按 `updated_at DESC` 排序每次轮询都重排,否则摸鱼回来就会撞上"工位换人"的错觉 |
40
- | 💡 灯光 | 有人在的楼层灯亮(running 或 idle 都算——摸鱼也开灯),只有彻底 stopped 的层才熄;天黑后街灯/霓虹/远景窗火全亮 |
41
- | ↳ 小一号 | 子代理:生在所在会话树的**根会话**工位层(而不是直接父代理那层),干活时聚在**直接**父代理桌边,不占工位 |
42
-
43
- 进出有戏:新会话直接出现在自己的工位层(从本层楼梯口走到工位,不再从街上爬通天);结束的走出门沿街远去淡出。
44
-
45
- ## 点击 = 真实数据
46
-
47
- 点任何市民,面板里是**真东西**:
48
-
49
- - **会话日志**:`/api/show/session` 拉来的最近 40 条消息流——用户输入、助手回复、
50
- 工具调用(带耗时),像 `tail -f` 一样贴底自动滚,面板开着每 6s 只刷日志框
51
- (面板本体原地补丁,不再整页重建闪烁)
52
- - **上下文仪表**:`134k / 200k (67%)` —— 分母是该会话的**真实** `maxContextTokens`
53
- - **委派链**:父/子会话可点击跳转,每行带 token 数和消息数
54
- - **点楼层**:该层的会话委派关系树(缩进、可点跳转)——工作层看"谁在这层干活、
55
- 带着哪些子代理",公共层看"谁在摸鱼";**工位在这层的人即使正外出休息也照样算在这层**
56
- (按归属工位层统计,不是按人此刻站在哪),点楼层永远能看全常驻名单
57
- - 点大楼:全员 roster + token 合计;点技能站:谁在用
58
-
59
- ## 街景与载具
60
-
61
- 街区不只是静态背景,会按**真实时间**自己演(`traffic.js`):
62
-
63
- | 事件 | 节奏 | 说明 |
64
- |---|---|---|
65
- | 🛸 UFO | 每天 0:00 | 午夜从街头飘过 |
66
- | ✈️ 飞机 | 每半点(`:30`) | 高空掠过,方向随机 |
67
- | 🚌 巴士 | 每 3 分钟 | 进站、开门、接走在站台等车的人,再开走 |
68
- | 🚗 进出场载具 | 跟随会话 | 新根会话坐出租车或小轿车送到门口(子代理直接淡入,不配车);结束的会话走到站台**体面地等车离场**,不再凭空消失 |
69
- | 🏗️ 盖楼 / 拆楼 | 跟随楼层增减 | 长楼层有施工动效;拆楼留 ~30s 缓冲(先让人撤离再慢慢消散,不穿帮) |
70
-
71
- > 定时全用真实 `Date.now()` 计算(`?hour=` 只冻结天色、不冻结这些事件)。
72
-
73
- ## 交互与显示
74
-
75
- 操作:滚轮缩放(九档 `0.3×–6×`)· 拖拽平移 · 点击查看 · **双击**在 1×/3× 间快速切 ·
76
- **F** 全街区 · **H**(或点左上角 logo)切纯净模式 · **Esc** 先关面板,没有面板时切纯净模式 ·
77
- 右上角 **中/EN** 一键切语言。
78
-
79
- - **纯净模式(zen)**:淡出所有 HUD(统计/动态/图例/缩放条/面板),只留街景,适合大屏常驻展示
80
- - **多语言(i18n)**:中文 / English 一键切;`?lang=en`/`?lang=zh` 直达,选择存 localStorage。
81
- 运行时文案走 `t()`,静态 DOM 走 `data-i18n` 属性填充
82
- - **背景音乐**:🎵/🔇 HUD 按钮(挨着 中/EN),纯 Web Audio 程序生成、零素材,
83
- 默认开、偏好存 localStorage;受浏览器自动播放策略限制,AudioContext 在首次
84
- 用户手势后才创建,关掉时真正 suspend(零 CPU),音频图与画面 rAF 循环完全独立
85
-
86
- ## 调试参数
87
-
88
- - `?hour=22.5` — 把昼夜定格在某个时刻(支持小数;只冻结天色,不冻结街景事件)
89
- - `?lang=en` / `?lang=zh` — 指定语言
90
- - kiosk 直链:`/#api=…&token=…`(token 立即从地址栏抹掉)
91
-
92
- ## 文件结构
93
-
94
- ```
95
- js/
96
- main.js 入口:setup → world → 输入/轮询
97
- api.js state 轮询 + session 详情客户端(content-type 防呆)
98
- world.js 快照 → 城市/市民 diff,渲染循环,拾取
99
- city.js 街区:楼(材质/楼层/霓虹/楼顶)、大堂、街道、视差天际线
100
- citizen.js 市民:楼层×楼梯 2D 路径规划 + 行为 FSM
101
- people.js 人物绘制:动物物种×毛色×配件池,眨眼/口型
102
- props.js 家具:工位/技能站/咖啡机/街机/鱼缸/猫爬架/窗/灯
103
- palette.js EDG32 + 建筑材质 ramp + 关键帧天空
104
- camera.js 整数吸附镜头(九档缩放 0.3×–6×)
105
- inspector.js 面板 + 实时会话日志查看器
106
- ticker.js 街区动态 feed
107
- music.js 背景音乐:纯 Web Audio 程序生成
108
- traffic.js 街景事件:UFO(0点)/飞机(每半点)/进出场载具/每 3 分钟 bus/盖楼·拆楼
109
- i18n.js 多语言词典(zh/en)+ 运行时 t() + 静态 data-i18n 填充
110
- util.js hash/PRNG/格式化
111
- ```
8
+ > 前端设计(街区结构、交互、嵌入模式、文件结构、前端渲染性能)见 hub 的
9
+ > [extensions/halo-city/DESIGN.md](https://github.com/turmind/halo-hub/blob/main/extensions/halo-city/DESIGN.md)。本文只讲服务端配套。
112
10
 
113
11
  ## 服务端配套
114
12
 
115
- - **部署:和 Halo 同源。** 页面所在域名下把 `/api/show/` 反代到 Halo,浏览器
116
- 只访问自己的 origin,也就不经过 CORS(`main.js` 自动连接时先试
117
- `location.origin`)。不要跨域部署:请求带 `credentials: 'include'`,跨域时
118
- 只有 `server.cors_origins` 里逐字列出的 origin 才拿得到 Allow-Credentials,
119
- 换域名就挂;前置 SSO 的 307 也会被浏览器报成跨域错误。nginx 写法见
120
- [halo-city/README.md](../../../halo-city/README.md#正式部署和-halo-同源)
121
- - `GET /api/show/session?ws=&id=` — 只读会话详情:裁剪后的消息日志 + 真实
122
- token 上限。鉴权同 `/api/show/state`(x-token;`full`/`observer` 可跨
123
- workspace 读取,其余 accessLevel 只能看自己 workspace,**且只能看自己
124
- token 铸造的会话**(id 以 `web_<accountId>_` 开头);点其它市民会 403,
125
- 前端在 inspector 里显示加载失败框而不是 transcript
13
+ - 接口契约见 [dev/api.md](../dev/api.md#show-world-snapshot):`GET /api/show/state`
14
+ (快照轮询)和 `GET /api/show/session?ws=&id=`(只读会话详情)的字段与鉴权。
15
+ - **鉴权二选一**:带 token(`x-token`,Web Token,按 accessLevel 限定可见范围;
16
+ 带了 token 就只认 token),或不带 token、靠同源的 admin 登录 cookie,按 `full`
17
+ 处理——admin 里的扩展页就是这样免 token 的。
18
+ - 独立部署(不进 admin、走 token)要和 Halo 同源反代 `/api/show/`,nginx 写法见
19
+ hub [README.md](https://github.com/turmind/halo-hub/blob/main/extensions/halo-city/README.md#和-halo-同源)。
126
20
  - `/api/show/state` — 无活跃 UIState 的会话(idle/stopped/重启后)token
127
21
  从会话文件头读取(按会话 `updatedAt` 缓存,未变化时不随轮询重读),不显示 0;含 `messageCount` 字段
22
+ - `/api/show/state` 带弱 `ETag` + `Cache-Control: private, no-cache`:哈希不含
23
+ `serverTime`,`uptime` 按分钟计入,所以服务器空闲时 7 秒一次的轮询绝大多数回
24
+ 空 body 的 304,每分钟最多一次 200。浏览器 `fetch` 默认缓存模式自动带
25
+ `If-None-Match`,扩展不用改;鉴权仍在最前,不同 scope 的调用方 ETag 不同
128
26
 
129
27
  > **`messageCount` 与 admin 的 `exchangeCount` 是两个语义,不要互相对齐**:
130
28
  > 这里读的是会话文件里的 `messageCount`(活跃 UI 日志的长度),城市只把它
@@ -133,19 +31,7 @@ js/
133
31
  > 轮次数,归档后不缩)。城市这边继续走文件头是有意的:它必须能在**没有活跃
134
32
  > SessionManager**的情况下降级出图,不依赖 db 镜像何时写入。
135
33
 
136
- > **`observer` token 的权限面**:除了 `/show/state` 的聚合计数外,`observer`
137
- > 还能调 `/show/session` 读取**任意 workspace** 任意会话的明细——最近 40 条
138
- > 消息(每条截断到 600 字,tool input 截到 200 字)。它是"看板/监控"用的
139
- > 全局只读角色,但这条 transcript 读取能力不止是计数;签发 observer token 时
140
- > 要知道它相当于给了跨 workspace 的会话内容只读权限,不要把它当成纯粹的
141
- > "数字仪表盘"凭证随手发放。
142
-
143
- ## 性能设计
144
-
145
- 原则:一个纯只读可视化页面,轮询**不得对服务端会话生命周期产生任何副作用**,
146
- 前端渲染成本随"画面里实际可见、实际变化的东西"伸缩,而不是随世界大小。
147
-
148
- ### 轮询零副作用(服务端)
34
+ ## 轮询零副作用(服务端)
149
35
 
150
36
  show 路由只用 `registry.peek()`(只查内存 Map,**绝不创建** SessionManager——
151
37
  构造函数是写重的:boot 孤儿回收会批量 stop 子会话行、还会往目录里播 `.halo/`)。
@@ -153,20 +39,3 @@ peek miss 时降级为**只读 sqlite 快照**:readonly 模式直开该 workspac
153
39
  `.halo/halo.db`(连接进程内缓存,DB 缺失静默降级为空),行直接投影成 wire shape。
154
40
  代价是非内存会话的数据滞后到上次持久化点、live 信号(lastTool/activeSkill)为空
155
41
  ——对可视化用途是正确取舍。详见 [dev/api.md](../dev/api.md#show-world-snapshot)。
156
-
157
- ### 前端渲染(js/ 各文件的注释有完整细节)
158
-
159
- - **palette 记忆化**(palette.js):`shade/tint/alpha/mix` 纯函数按输入两级 Map
160
- 缓存;alpha 比例 8-bit 量化、sky 颜色按 5s 量化,确定性验证像素 0 差异
161
- - **vGradient 缓存**(city.js):sky/beach/sea/haze 的 `createLinearGradient`
162
- 按几何+颜色 key 缓存,相机静止时 steady-state 分配归零
163
- - **skyline offscreen layer**(city.js `Layer`):天际线剪影带在相机静止时每 5s
164
- sky tick 渲一次,其余帧一次 blit。**gradient 填充不得入 layer**——Skia 的
165
- gradient dither 锚定 device y,带偏移 blit 会整片移相(drawSea 因此回退直绘,
166
- 教训详见 [memory/2026-07-02-canvas-gradient-dither.md](../../memory/2026-07-02-canvas-gradient-dither.md))
167
- - **视口剔除**:楼整体(`buildingVisible`)与楼内逐层(`drawFloors`)双级剔除;
168
- 市民绘制列表同样剔除(但位置模拟不剔——否则回屏跳变)
169
- - **stateSig 签名比对**(main.js):快照签名(剔除 serverTime、uptime 取分钟粒度)
170
- 未变时跳过整个 ingest/diff/rebuild,空闲服务器的轮询近乎零成本
171
- - **后台停轮询**(api.js / inspector.js):`document.hidden` 时停止轮询,
172
- `visibilitychange` 恢复并立即刷一次——后台标签页零流量零 CPU
@@ -161,8 +161,8 @@ Slack cron-dispatcher plugs into the unified cron/dispatcher.ts registry. At fir
161
161
 
162
162
  1. Retrieve the Slack account (must be enabled)
163
163
  2. Parse the target's `chatId` (format: `D…`, `C…`, or `C…:thread_ts`)
164
- 3. Call `postMessage()` with the chatId (and threadTs if present)
165
- 4. Return dispatch result (ok / error)
164
+ 3. Split the text with `splitText(text, SLACK_TEXT_LIMIT)` (35 000 chars, exported from `event-adapter.ts`, the same limit as chat replies), run each chunk through `formatForSlack`, and call `postMessage()` for each in order with the chatId (and threadTs if present). A failed chunk stops the text send.
165
+ 4. Return dispatch result: one row for the text (`ok`, or `chunk i/n: <error>`), then one row per `MEDIA:` upload. Uploads still run after a text failure.
166
166
 
167
167
  If no explicit chatId is provided, dispatch fails with a clear message. This prevents silent failures in a shared-bot scenario where the "last messager" might be unrelated to whoever set up the cron.
168
168
 
@@ -30,6 +30,9 @@ Defines the persisted-data format for every Halo surface. Format changes must re
30
30
  │ ├── runs.db # Run ledger: (workspace, session_id) rows for server-driven runs in flight; steady state empty (running_sessions)
31
31
  │ ├── a2a.db # A2A: inbound tasks, push configs + outbox, outbound dispatches (see design/a2a.md)
32
32
  │ ├── extensions/<id>/ # Installed Canvas preview extensions (halo-extension.json + bundle)
33
+ │ ├── stt-models/ # On-device STT model files, downloaded on demand (see design/stt-local.md)
34
+ │ │ ├── sense-voice-zh-en-ja-ko-yue-2024-07-17/ # model.int8.onnx + tokens.txt (in-progress downloads: <file>.part)
35
+ │ │ └── silero-vad-v4/silero_vad.onnx
33
36
  │ ├── logs/ # Runtime logs (server.log)
34
37
  │ │ ├── evo/ # Per-evo-run wrapper logs
35
38
  │ │ └── cron/ # Per-cron-run cli stdout/stderr (30-day retention)
@@ -136,7 +139,7 @@ Path: `.halo/sessions/{agentId}/{sessionId}.json`
136
139
  }
137
140
  ```
138
141
 
139
- Written as **compact JSON** (no indent) by all five writers of this path — nothing reads it by eye. `messageCount` is `messages.length` and has a single writer (`saveSessionToFile`); it *shrinks* when a compact archives history, so the lifetime count for listings is `exchangeCount` (mirrored to sqlite, see [agent_sessions](#agent_sessions)). See [session.md](session.md#session-file-format).
142
+ Written as **compact JSON** (no indent) by all five writers of this path — nothing reads it by eye. `messageCount` is `messages.length` and has a single writer (`saveSessionToFile`); it *shrinks* when a compact archives history, so the lifetime count for listings is `exchangeCount` (mirrored to sqlite, see [`agent_sessions`](#tables)). See [session.md](session.md#session-file-format).
140
143
 
141
144
  ### Archive segment file (UI-log archiving)
142
145
 
@@ -669,7 +672,7 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
669
672
  - `schema.sql` / each db's `CREATE_SQL` always describes the FULL current shape, so a fresh db is complete after the CREATEs alone
670
673
  - Every change to an already-existing db gets a numbered slot in that file's ordered migration list — `HALO_MIGRATIONS` (`db/index.ts`), `CRON_MIGRATIONS` (`db/cron-db.ts`), `A2A_MIGRATIONS` (`db/a2a-db.ts`); `channel-db.ts` / `evo-db.ts` / `runs-db.ts` currently pass an empty list. `runMigrations(sqlite, list)` (`db/migrate.ts`) runs `list[user_version..]` in order, each in its own transaction, stamping `PRAGMA user_version = i + 1` after each
671
674
  - A fresh db is also at `user_version 0` and runs the whole list, so every slot must be a no-op against the current shape (`addColumnIfMissing`, `CREATE … IF NOT EXISTS`). A CHECK constraint can't be altered in SQLite: the slot rebuilds the table (create `_v2`, copy rows, drop, rename) and skips when `sqlite_master` already shows the new constraint — `HALO_MIGRATIONS` v2 (`disabled_items.scope` admits `'extension'`) is the pattern
672
- - Current slots worth knowing: `HALO_MIGRATIONS` v2 — the `disabled_items` rebuild above (rows kept). `CRON_MIGRATIONS` v5 — `cron_jobs.working_dir TEXT NULL` (job folder → `halo cli --dir`), `cron_jobs.stateless INTEGER NOT NULL DEFAULT 0` (1 = fresh session every run), `cron_runs.session_id TEXT NULL` (the session that run used); column semantics in [cron.md](cron.md)
675
+ - Current slots worth knowing: `HALO_MIGRATIONS` v2 — the `disabled_items` rebuild above (rows kept). `CRON_MIGRATIONS` v5 — `cron_jobs.working_dir TEXT NULL` (job folder → `halo cli --dir`), `cron_jobs.stateless INTEGER NOT NULL DEFAULT 0` (1 = fresh session every run), `cron_runs.session_id TEXT NULL` (the session that run used). `CRON_MIGRATIONS` v6 — `cron_runs.workspace_path TEXT NULL` (the workspace the run fired against, where a stateless run's session lives), backfilled from the job row (`UPDATE … SET workspace_path = (SELECT workspace_path FROM cron_jobs …) WHERE workspace_path IS NULL`), so rows whose job is already gone stay NULL; column semantics in [cron.md](cron.md)
673
676
  - Append only — never reorder or edit a shipped slot. A db opened by a newer halo (user_version > list length) logs a warning and continues
674
677
  - Do not drop or rename existing columns
675
678
  - New channel types: add rows with a new `channel_type` value + define config shape in the channel's `accounts.ts` adapter
@@ -688,3 +691,4 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
688
691
  | v1+ | 2026-09-18 | Relay + liveness (additive, no version bump): `agent_sessions.reply_to` column (idempotent ALTER); session file gains `lastActivityAt` beside `output`, read back by `get_session_output` for released sessions. |
689
692
  | v1+ | 2026-09-20 | Schema migrations moved from ad-hoc boot-time ALTERs to `PRAGMA user_version` + ordered per-db migration lists (`db/migrate.ts`); existing dbs pick up at slot 0 and every slot is idempotent. Session-message + WS frame types moved to `@turmind/halo-core/protocol` (re-exported from their old server paths; no wire change). |
690
693
  | v1+ | 2026-10-10 | Model registry (additive): per-model `endpoints:`, `maxContextWindow`, `compressAt`; recommended `contextWindow` ≤ 272000; registry context / compressAt / verbosity defaults are scaffold-only; installer validates model fields and asks consent for URLs new to a provider. `HALO_MIGRATIONS` v2: `disabled_items.scope` admits `'extension'`. `CRON_MIGRATIONS` v5: `cron_jobs.working_dir`, `cron_jobs.stateless`, `cron_runs.session_id`. |
694
+ | v1+ | 2026-10-13 | `CRON_MIGRATIONS` v6 (additive): `cron_runs.workspace_path`, backfilled from the job row (NULL where the job is already gone). |
@@ -0,0 +1,106 @@
1
+ # On-device speech-to-text (`stt-local`)
2
+
3
+ A canvas extension that declares capability `stt-local` can stream microphone audio to the Halo server and get back text. Everything runs on the server: SenseVoice-Small (int8) decodes, Silero VAD cuts the audio into segments, and `sherpa-onnx-node` hosts both models. There is no cloud call and no credential to configure. Model files are downloaded the first time they're needed. Not mounted in AgentCore mode, since that mode runs neither the extension watcher nor any WS surface.
4
+
5
+ Code: `packages/server/src/stt/models.ts` (model files + downloader), `stt/engine.ts` (recognizer, VAD, decode queue), `routes/stt-ws.ts` (WS route), `extensions/watcher.ts` (`reconcileSttModels`), mounted in `index.ts` next to the Transcribe proxy.
6
+
7
+ ## Models and licenses
8
+
9
+ | File (under `~/.halo/global/stt-models/`) | Bytes | Model | License |
10
+ |---|---|---|---|
11
+ | `sense-voice-zh-en-ja-ko-yue-2024-07-17/model.int8.onnx` | 239233841 | SenseVoice-Small int8 (FunAudioLLM / Alibaba), sherpa-onnx export | [FunASR Model License v1.1](https://github.com/modelscope/FunASR/blob/main/MODEL_LICENSE) |
12
+ | `sense-voice-zh-en-ja-ko-yue-2024-07-17/tokens.txt` | 315894 | its token table | same |
13
+ | `silero-vad-v4/silero_vad.onnx` | 1807522 | Silero VAD v4 | MIT |
14
+
15
+ - **FunASR Model License v1.1** allows free use, copying, modification and sharing, provided you credit the source and the authors and keep the model's name (§2.2). The attribution goes on the **halo-hub extension's description page**. The hub ships no weights: the server pulls them from the sources below, the extension never does.
16
+ - The VAD is the v4 export (1.8 MB). The 643 KB export the first evaluation used exists only on GitHub releases, not on HF or ModelScope. On sherpa's `test_silero_vad.wav`, the two cut identical segments.
17
+ - Language is `auto`. SenseVoice identifies Mandarin, Cantonese, English, Japanese and Korean by itself. Setting a language on the recognizer or the stream barely changed results in testing, so there is no `lang` parameter. Output is whatever the model writes (Simplified for Mandarin); there's no Traditional conversion.
18
+
19
+ ## Download
20
+
21
+ Each file has its sha256 and size pinned in `STT_MODEL_FILES` (`models.ts`), and response headers are never trusted. Each file tries its sources in order, falling back file by file:
22
+
23
+ 1. **Hugging Face**, pinned to a commit: `csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17@2365baea…` and `csukuangfj/vad@fba88cd2…`.
24
+ 2. **ModelScope** `https://www.modelscope.cn/api/v1/models/<repo>/repo?Revision=master&FilePath=<file>` (`pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue`, `pengzhendong/silero-vad` `v4/silero_vad.onnx`). Only this API form 302s to a CDN that honours Range. `resolve/master` returns a full 200 to a Range request.
25
+
26
+ Mechanics:
27
+
28
+ - Writes `<file>.part` and resumes it with `Range: bytes=<have>-`. A **206** whose `Content-Range` starts at `<have>` appends. A **200** rewrites from zero. A **416** deletes the `.part` and moves to the next source. The sha256 is computed while writing (on a resume, the existing prefix is hashed first). Only a `.part` that matches **both** size and sha gets renamed into place.
29
+ - A sha / oversize mismatch deletes the `.part` and moves on. An HTTP error, a short body or a timeout **keeps** it, and the next source resumes it: the bytes are pinned by sha, not by source. A `.part` that is already full-size (process killed between verify and rename) is re-hashed and renamed without any request.
30
+ - Timeouts: **15 s** from request to first body byte (redirects included), then **30 s** without a new chunk counts as a stall. Either one moves to the next source.
31
+ - Transport is `node:http(s)` with ≤ 5 redirects, keeping headers (Range) on every hop. It is not `fetch`, because Node 22's bundled undici (6.28) trips an internal `assert(!this.paused)` when a `Connection: close` body ends while the parser is paused under backpressure. That surfaces as an **uncaught exception**, which kills the server (reproduced locally with a 64–100 KB body). A `User-Agent` is mandatory: ModelScope's CDN returns 403 without one (`node:http` sends none by default).
32
+ - **Single flight**: one `ensure()` promise. Concurrent triggers join it.
33
+ - **Shutdown** (`gracefulShutdown` → `stt.close()`) aborts the running request and keeps the `.part`. The next boot resumes it (logged as `resuming at N bytes`).
34
+
35
+ Measured on the dev box (temp HOME): HF, all three files, 3.5 s total (228 MB model in 2.5 s). ModelScope-only (HF forced to fail): tokens + VAD in 2.4 s. Model aborted at 43 MB, then resumed from ModelScope in 10.9 s. SIGTERM mid-download then reboot resumed from HF in 2.3 s. Every sha matched.
36
+
37
+ ### State machine
38
+
39
+ ```
40
+ missing ──ensure()──▶ downloading{received,total} ──all files ok──▶ ready
41
+ ▲ │
42
+ │ └─ some file: every source failed ─▶ error{message}
43
+ └──────── next ensure() (WS connect / extensions rescan) retries ◀─┘
44
+ ```
45
+
46
+ - `ready` means every final file exists **with its pinned size**. Boot doesn't re-hash: only verified files were ever renamed in.
47
+ - `received` / `total` count bytes across all files (already-present files included), so the progress bar covers the whole set.
48
+ - Pushes go to subscribers: state changes immediately, progress at most every 500 ms (≤ 2/s).
49
+
50
+ ### Triggers
51
+
52
+ - **WS connect** (`routes/stt-ws.ts`): if the model isn't ready, `ensure()` runs, and that socket then receives `model` frames until `ready` or `error`.
53
+ - **Extensions reconcile** (`extensions/watcher.ts` → `reconcileSttModels`): after `start()`'s first scan and after every `rescanAndBroadcast()` scan (install / uninstall / watcher event). If any valid extension declares `stt-local` and the state is neither `ready` nor `downloading`, it calls `ensure()`. This makes installing the extension the moment the ~240 MB fetch starts, and it retries an `error` on the next rescan. The watcher doesn't run in AgentCore mode.
54
+
55
+ ## Engine
56
+
57
+ - `sherpa-onnx-node` is `await import()`ed on first `load()`. The CJS package's ESM namespace only has `OnlineRecognizer` + `default`, so `OfflineRecognizer` / `Vad` come off `default`. A failure (unsupported platform, missing native package) is a `SttError('engine')` on that stream and is retried on the next stream. It can never fail server boot.
58
+ - **One recognizer**, created on first use: `numThreads = clamp(floor(os.availableParallelism() / 2), 1, 4)`, `provider: 'cpu'`, `language: ''`, `useInverseTextNormalization: 1`. Load takes ~0.9 s. **It stays resident, about 400 MB RSS** (temp server: 472 MB total after a session). There is no idle release: dropping the recognizer + `global.gc()` for 20 s gave back only 13–119 MB of the ~325 MB it added (varied run to run), and reloading climbs straight back to ~400 MB. sherpa has no explicit free, and the onnxruntime / glibc arenas don't return to the OS.
59
+ - **Global decode queue**: FIFO, concurrency 1 server-wide, via `decodeAsync` (libuv pool; the event loop keeps running). 10 s of speech decodes in ~0.2–0.3 s.
60
+ - **Caps → `busy`**: more than **32** segments waiting server-wide, or more than **60 s** of one stream's audio queued or decoding. The stream fails, and its own queued segments are dropped.
61
+ - Results: `lang` `<|zh|>` → `zh` (`zh` / `en` / `yue` / `ja` / `ko`). A segment whose text is empty after trimming is dropped (no frame), but still counts in the segment log.
62
+
63
+ ### VAD (one per stream)
64
+
65
+ Silero v4, 512-sample windows at 16 kHz, internal buffer 30 s. Constants in `engine.ts`:
66
+
67
+ | Constant | Value | Why |
68
+ |---|---|---|
69
+ | `VAD_THRESHOLD` | 0.5 | 0.3 split Cantonese into fragments; the English head loss 0.5 caused is fixed by padding instead |
70
+ | `VAD_MIN_SILENCE_S` | 0.5 | Silence that ends a segment. It is also the final's latency after speech stops. Shorter values cut mid-sentence pauses into separate, worse decodes |
71
+ | `VAD_MIN_SPEECH_S` | 0.25 | Shorter blips (clicks, breaths) aren't speech |
72
+ | `VAD_MAX_SPEECH_S` | 20 | Hard cut for a monologue. SenseVoice is non-streaming, so nothing comes back until a segment ends. This bounds latency and per-segment memory |
73
+ | `PAD_BEFORE_S` | 0.3 | Raw audio prepended. Silero fires a few windows into the first syllable, and at 0.5 it swallowed English heads ("Fous to foreigners…") |
74
+ | `PAD_AFTER_S` | 0.2 | Soft endings fall under the threshold early. 0.3 + 0.2 = min-silence, so at a normal pause padding doesn't reach into the neighbour |
75
+
76
+ Each stream keeps a 30 s ring of raw samples (it must reach back past a 20 s segment plus pad when that segment is emitted), and each segment is cut from the ring. The pre-pad is clamped to the previous segment's padded end, so no audio is decoded twice. The final's `start` / `end` are the **padded** bounds: exactly the audio that was decoded.
77
+
78
+ **Concatenation test** (zh / en / yue + ja / ko from the SenseVoice HF repo's `test_wavs/`, joined with 1.2 s of silence, streamed through `engine.ts` in 100 ms s16le frames, each final compared with a whole-file decode of the same clip): 5/5 clips produced one segment each, with the right language. **No head or tail word was dropped** in any language: after stripping punctuation and spaces, each streamed text has exactly as many characters as its whole-file decode (zh 13/13, en 66/66, yue 15/15, ja 34/34, ko 17/17). Exact string match was 1/5 (yue). The other four are substitutions, not losses: zh 开饭→开放 (one homophone, at the head), en "code"→"gold" (at the tail; the clip actually says *gold*), ja an extra `、`, ko word-spaced Hangul (identical letters). Isolating which differences come from padding: decoding the unpadded VAD segment alone already gives the ja / ko variants, while zh 开放 and en "gold" appear only with the pre-pad. Both are single-token flips, not dropped audio. For context, a sweep over pre/post pads of 0–0.5 s never got more than 2/5 exact. On sherpa's 60 s English `test_silero_vad.wav`, 8 of 10 padded segments decode identically to the unpadded ones. One gains a comma. One changes both ways: "Capital in thet century argues for a 4%" becomes "Capital in the 21st century argue for a a 4%".
79
+
80
+ ## WS `/api/stt/stream?ext=<id>`
81
+
82
+ `noServer` ws server behind `ws/upgrade-router.ts`, same structure as the Transcribe proxy (`routes/transcribe-ws.ts`).
83
+
84
+ - **Handshake**: login cookie (`isAuthenticated(getTokenFromCookieHeader(cookie))`), else **401**. `ext` must be installed and declare `stt-local`, else **403**. Frames are capped at 1 MiB (`maxPayload`). Every socket has an `error` listener, so a protocol violation (oversize → 1009, bad UTF-8 → 1007) closes only that socket.
85
+ - **Server → client** (text JSON):
86
+ - `{"type":"model","state":"missing"|"downloading"|"ready"|"error","received"?,"total"?,"message"?}`: always the **first** frame. While the model isn't ready, more `model` frames follow (progress ≤ 2/s, plus every state change). `received` / `total` are present only for `downloading`, `message` only for `error`.
87
+ - `{"type":"ready"}`: model ready **and** engine loaded. Audio is accepted from here on.
88
+ - `{"type":"final","start":<s>,"end":<s>,"text":"…","lang":"zh"|"en"|"yue"|"ja"|"ko"}`: one per non-empty segment. `start` / `end` are seconds (3 decimals) from this connection's first accepted audio sample.
89
+ - `{"type":"error","code":"model"|"engine"|"busy"|"bad-request"|"io","message":"…"}`, then close **1011**. `model` = download failed (the reason is also in the preceding `model` frame's `message`), `engine` = native load / VAD / decode failure, `busy` = queue caps above, `bad-request` = a text frame other than `{"type":"end"}`, `io` = anything else.
90
+ - **Client → server**:
91
+ - binary = PCM s16le mono 16 kHz, any frame length (an odd trailing byte carries over). **Binary sent before `ready` is dropped**, because there's no stream yet to feed. A client should start sending only after `ready` (`start` / `end` count from the first audio sent after it).
92
+ - text `{"type":"end"}`: flush the VAD, wait for every remaining segment of this connection to be decoded and its `final` sent, then close **1000**. Sent before `ready`, it closes 1000 at once (nothing was accepted).
93
+ - **Disconnect**: this connection's segments that haven't started decoding are removed from the global queue. A segment already decoding finishes, and its result is discarded.
94
+ - **Shutdown**: open sockets close 1001 and the download is aborted, keeping its `.part`.
95
+
96
+ ## Logs
97
+
98
+ `[STT] …` only, with counts and durations: `stream open ext=…`, `stream closed after Ns ext=… segments=N audio=Ns`, `stream error ext=… code=…`, `engine loaded in Nms (threads=N)` / `engine load failed: …`, `model download start`, `downloaded <file> from <source> (N MB in Ns)`, `download <file> from <source> failed: <reason>`, `<file>: resuming at N bytes`. **Recognized text and audio are never logged.** The server's default level is `warn`, so these `console.log` lines show up at `general.logging.level: info` (or `HALO_LOG_LEVEL=info`).
99
+
100
+ ## Known limits
101
+
102
+ - **Proxies**: neither `fetch` nor `node:http(s)` reads `HTTPS_PROXY` / `HTTP_PROXY` on Node 22 by default (verified: with an unreachable `HTTPS_PROXY`, both connected directly). `NODE_USE_ENV_PROXY=1` (experimental in Node 22, prints `UNDICI-EHPA` warnings) makes both honour it. A server behind a mandatory egress proxy can't download the models without it. The workaround is to drop the three files into `~/.halo/global/stt-models/` by hand with the exact names and sizes above.
103
+ - ~400 MB resident once any stream has loaded the engine (no idle release, see above). The decode burst uses up to 4 cores.
104
+ - One decode at a time: with many concurrent speakers, latency grows linearly until `busy`.
105
+ - No partial results (SenseVoice is non-streaming): a final arrives ~0.5 s after the speaker pauses, at most 20 s after a segment starts.
106
+ - Desktop (win-x64 / darwin-arm64): `stage-runtime.mjs` `installSherpaBinary()` ships the target's `sherpa-onnx-<platform>-<arch>` package (see [dev/desktop-packaging.md](../dev/desktop-packaging.md)). A build without it fails streams with `engine`, never server boot. The macOS addon declares minimum 14.0; older macOS is unverified.
@@ -62,7 +62,7 @@ File: `packages/server/src/routes/web.ts`
62
62
 
63
63
  All public endpoints require `x-token` header (or `?token=` query param) with a valid account token. No cookie auth needed.
64
64
 
65
- > These paths are listed in `PUBLIC_PATHS` (`middleware/auth.ts`) so they bypass the admin cookie gate: `/api/web/chat`, `/api/web/sessions` (POST mint + GET list), `/api/web/stop`, `/api/web/history`, `/api/web/subscribe`, `/api/web/file`, and `/api/show/state` (the [halo-city](../../../halo-city/) snapshot — see [dev/api.md](../dev/api.md#show-world-snapshot)). The server CORS allowlist includes the `x-token` header, so browser-based custom frontends (e.g. halo-city) can call these cross-origin.
65
+ > These paths are listed in `PUBLIC_PATHS` (`middleware/auth.ts`) so they bypass the admin cookie gate: `/api/web/chat`, `/api/web/sessions` (POST mint + GET list), `/api/web/stop`, `/api/web/history`, `/api/web/subscribe`, `/api/web/file`, and `/api/show/state` (the [halo-city](https://github.com/turmind/halo-hub/tree/main/extensions/halo-city) snapshot — see [dev/api.md](../dev/api.md#show-world-snapshot)). The server CORS allowlist includes the `x-token` header, so browser-based custom frontends (e.g. halo-city) can call these cross-origin.
66
66
 
67
67
  ### POST `/api/web/chat`
68
68
 
@@ -65,7 +65,7 @@ The wecom cron-dispatcher (`channels/wecom/cron-dispatcher.ts`) requires an expl
65
65
  1. **Explicit `chatId` on the target** — set when the cron was created from inside a WeCom chat (auto-pinned via `CommandContext.channel.chatId`). Shape: `<userid>` for a single chat, `<chatid>` for a group — exactly what `pickConversation` cached as `chatKey`. `sendMessage(chatId, { msgtype: 'markdown', … })` omits `chat_type`; the server's compat mode (`chat_type = 0`) resolves single vs group from the id.
66
66
  2. **Admin-UI cron without explicit target** — throws `wecom cron target requires an explicit chatId …` and the run fails visibly. There is no `/search` route (WeCom exposes no directory API on this channel), so the target must be typed as `wecom:<accountId>:<chatId>`.
67
67
 
68
- Because the push rides the handler's socket, the dispatcher borrows it through the exported `liveClients: Map<accountId, WSClient>` (set on `authenticated`, cleared on stop / kick / auth exhaustion). No live client → `{ ok: false, error: 'wecom long-connect not active' }` rather than queueing. WeCom additionally refuses pushes to a user / group that has never messaged the bot, and rate-limits each conversation to 30 msgs/min · 1000/hour (replies and pushes combined).
68
+ Because the push rides the handler's socket, the dispatcher borrows it through the exported `liveClients: Map<accountId, WSClient>` (set on `authenticated`, cleared on stop / kick / auth exhaustion). No live client → `{ ok: false, error: 'wecom long-connect not active' }` rather than queueing. The report is split with `splitText(text, WECOM_TEXT_LIMIT)` (5000 chars, exported from `event-adapter.ts`, the same limit as chat replies) and the chunks are sent in order as `markdown` messages, with no formatter. A failed chunk stops the send and is recorded as `chunk i/n: <error>`. WeCom additionally refuses pushes to a user / group that has never messaged the bot, and rate-limits each conversation to 30 msgs/min · 1000/hour (replies and pushes combined).
69
69
 
70
70
  **`MEDIA:` is not implemented for cron** — the dispatcher declares no `supportsMedia`, so `dispatchToTargets` hands it the original text with `MEDIA:` lines intact (the path degrades to visible text, same caveat as telegram / feishu in [cron.md](cron.md#dispatch-model)). The realtime uploader (`sendWecomMedia` in `handler.ts`) already falls back to `sendMediaMessage(chatid, …)` for a route without a `req_id` (restored at startup); wiring cron up means calling it with `{ reqId: '', chatId }` and flipping `supportsMedia: true`.
71
71
 
@@ -33,7 +33,7 @@ Runtime names (`runtime:` value → class): `bedrock-invoke` → `BedrockAgent`,
33
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) |
34
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). |
35
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'`. |
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 |
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 the only generation listed; `mimo-v2.5(-pro)` (officially retired 2026-10-21) were dropped from the template in revision 2026101401 |
37
37
  | `aws-bedrock-mantle` | `bedrock-mantle` | `MantleAgent` | `mantle-agent.ts` | OpenAI GPT-6.1 Sol, GPT-6 (Astra / Sol / Luna) 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-24): us-east-1 serves GPT-6.1 Sol + GPT-6 Sol / Luna + every 5.6 model (404s Astra; GPT-6.1 Sol probed 2026-09-30, us-east-1 only); us-east-2 serves only the 5.6 trio; us-west-2 serves Astra + 5.6 Terra / Luna. Template ships all three as `endpointPresets` (us-east-1 / us-west-2 / us-east-2, default us-east-1, `openai.gpt-6-sol` default model) and pins each model's regions with per-model `endpoints:` (Astra us-west-2; GPT-6.1 Sol / GPT-6 Sol / Luna us-east-1; GPT-5.6 Sol us-east-1 + us-east-2; Terra / Luna none = all presets), so the form only offers — and on a model switch moves to — a region that serves the model; `extractRegionFromHost` falls back to us-east-1. GPT-6 Astra / Sol / Luna: 1,050,000 context / 128,000 max output; GPT-6.1 Sol 1M / 131,072 (card; Mantle accepts any `max_output_tokens` ≥ 16, so the cap isn't enforced server-side); same effort (none/low/medium/high/xhigh/max) + verbosity surface. Input beyond 272K tokens bills at 2x, so every entry ships `contextWindow: 272000` (what a new agent gets) with the 1M-class figure as `maxContextWindow`; raise `context.maxTokens` past 272K only on purpose. 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. Streams (`stream: true`, Responses API SSE folded by `readResponsesStream` in the same file): `response.output_text.delta` frames go out live, the terminal `response.completed` / `response.incomplete` frame carries the full final response object; this host sends **no** `data: [DONE]` (live-probed 2026-09-29). |
38
38
  | `aws-bedrock-openai` | `bedrock-mantle` | `MantleAgent` | (reuses `mantle-agent.ts`) | Frontier models on Bedrock via the **bedrock-runtime** host's OpenAI Responses surface (`https://bedrock-runtime.<region>.amazonaws.com/openai/v1` — AWS's recommended endpoint for new apps; Mantle is the compatibility one). Same wire format, so its yaml just says `runtime: bedrock-mantle` + `extractRegionFromHost` accepts both hostnames (SigV4 signs with the host's region — a mismatched region 401s "Credential should be scoped to a valid region"). Rules that differ from Mantle: model ids must be cross-Region inference profiles (`global.*` / `us.*`; bare ids 400 "on-demand throughput isn't supported"); **no region split** — live-probed 2026-09-24, all nine `global.*` ids answer 200 from us-east-1 / us-west-2 / eu-west-1 / ap-northeast-1 alike; **no server-side tools** — `tools:[{type:"web_search"}]` 400s "web search is not supported for this request", which is why the web-search skill's deep gear stays on Mantle. Ships the same seven **GPT-6.1 / 6 / 5.6** entries as the Mantle template (`global.openai.gpt-6.1-sol`, `global.openai.gpt-6-{astra,sol,luna}`, `global.openai.gpt-5.6-{sol,terra,luna}`; identical effort none/low/medium/high/xhigh/max + verbosity surface per each model's own validation error — except Astra and GPT-6.1 Sol 400 on `none`, which the runtime never sends), plus **Grok 4.6** (500K ctx, 2x input price past 200K, max output undisclosed → 64000 carry-over, effort **only** low/medium/high/xhigh — `none`/`max` 400, so no Max preset; vision OK; $2/$6) and **Kimi K3** (1M ctx / 131072 out, effort none…max all accepted, ladder advisory on short prompts; vision OK; $3/$15). All: implicit prompt caching verified (run 1 `cache_write_tokens`, runs 2+ `cached_tokens` ≈ prefix), no reasoning summaries. Streams like Mantle (same event vocabulary) but this host **does** append a trailing `data: [DONE]`, which `readSseJson` skips; grok-4.6 emits a `reasoning` output item (no summary text deltas) before the `message` item. |
39
39
 
@@ -6,7 +6,7 @@ Auth: most `/api/*` routes require a valid JWT cookie (`halo_token`). Exceptions
6
6
 
7
7
  **Trust model.** The admin cookie is a single-tenant, all-or-nothing credential: there is one password (`~/.halo/secrets/config.yaml`), one JWT secret, and no roles. A valid `halo_token` equals full control of the server process — every workspace's files (`/api/files/*` project-scoped, `/api/fs/browse` unrestricted), shell via the WS terminal, channel credentials, git credentials, cron jobs, and the ability to mint web-channel tokens at any access level. Routes under the cookie therefore don't re-check "who" beyond the cookie itself; per-user isolation is the job of channel accounts (`x-token` with `readonly` / `workspace` / `full`), not of the admin. Consequences worth knowing when adding routes: (1) never put a cookie-gated route in `PUBLIC_PATHS` to "make it easier for a client" — that's the entire boundary; (2) a route that accepts a path or id from the client still needs traversal / shape checks (`isSafeIdSegment`, `assertPathAllowed`), because the *web-channel* surface reuses the same helpers and is multi-user; (3) if multi-user admin ever lands, the first change is a role claim in the JWT plus per-route checks, and this paragraph becomes the checklist.
8
8
 
9
- Compression: every response negotiates gzip/deflate via `hono/compress` (`threshold: 1024` — bodies under 1 KiB pass through uncompressed), mounted after CORS so it covers both `/api/*` JSON and the served admin static assets. `text/event-stream` (SSE) is excluded, so `/api/web/chat`'s streamed reply is unaffected; WS upgrades never enter the Hono fetch pipeline either.
9
+ Compression: every response negotiates gzip/deflate via `compressResponses` (`middleware/compress.ts`, wrapping `hono/compress`; `threshold: 1024` — bodies under 1 KiB pass through uncompressed). The threshold holds for every compressible body: one without `Content-Length` (`c.json` / `c.text`) is read up to 1 KiB first and, if it ends there, sent with a `Content-Length`. A 304 carries `Vary: Accept-Encoding`, merged into any existing `Vary`. Mounted after CORS so it covers both `/api/*` JSON and the served admin static assets. `text/event-stream` (SSE) is excluded, so `/api/web/chat`'s streamed reply is unaffected; WS upgrades never enter the Hono fetch pipeline either.
10
10
 
11
11
  ## Health
12
12
 
@@ -96,6 +96,7 @@ Installed extensions are directories under `~/.halo/global/extensions/<id>/` wit
96
96
  | DELETE | `/api/extensions/:id` | cookie | Remove the directory. 400 for an invalid id shape, 404 if not installed. Returns `{ok, id}` |
97
97
  | GET | `/api/extensions/:id/:version/:token/<asset>` | **path token, no cookie** | Static file from the extension directory. 401 unless `verifyScopedToken(token,'ext')`; 404 if `version` ≠ installed version or the asset escapes the directory. Response headers: `Access-Control-Allow-Origin: *`, `Referrer-Policy: no-referrer`, `Cache-Control: public, max-age=31536000, immutable` (the version segment is the cache key) |
98
98
  | WS | `/api/transcribe/stream?ext=<id>&lang=<auto\|xx-XX>` | cookie | Streaming transcription proxy to Amazon Transcribe (`routes/transcribe-ws.ts`; not mounted in AgentCore mode). Handshake 401 without the login cookie, 403 unless `ext` is installed and declares capability `transcribe`. Client → binary PCM s16le mono 16 kHz, text `{"type":"end"}` (flush, server closes 1000). Server → `{"type":"ready"}` (on connect; stream audio right after it), `{"type":"partial"\|"final",start,end,text,lang}` (seconds from the stream's first audio byte), `{"type":"error",code,message}` then close; `code` ∈ `credentials` \| `denied` \| `limit` \| `bad-request` \| `io`. Region / languages / credentials from the extension's `ext-<id>` settings (default chain when no keys) — see [design](../design/canvas-extensions.md#streaming-transcription-proxy) |
99
+ | WS | `/api/stt/stream?ext=<id>` | cookie | On-device speech-to-text — SenseVoice + Silero VAD via sherpa-onnx, no cloud (`routes/stt-ws.ts`; not mounted in AgentCore mode). Handshake 401 without the login cookie, 403 unless `ext` is installed and declares capability `stt-local`. Server → `{"type":"model",state,received?,total?,message?}` first (`state` ∈ `missing` \| `downloading` \| `ready` \| `error`; a missing model is downloaded from here, progress pushed ≤ 2/s), `{"type":"ready"}` once model + engine are loaded (binary sent before it is dropped), `{"type":"final",start,end,text,lang}` per VAD segment (seconds from the connection's first accepted sample; `lang` ∈ `zh` \| `en` \| `yue` \| `ja` \| `ko`), `{"type":"error",code,message}` then close 1011; `code` ∈ `model` \| `engine` \| `busy` \| `bad-request` \| `io`. Client → binary PCM s16le mono 16 kHz, text `{"type":"end"}` (remaining finals, then close 1000). No `lang` parameter — see [design/stt-local.md](../design/stt-local.md#ws-apisttstreamextid) |
99
100
  | GET | `/api/jupyter/status` | cookie | Notebook kernels (`routes/jupyter.ts`; not mounted in AgentCore mode). `{ enabled, available, version?, error?, hint? }` — never starts the server; disabled → `enabled:false, available:false`; Jupyter missing → `available:false` + `error` + `hint: 'pip install jupyter_server ipykernel'` |
100
101
  | GET | `/api/jupyter/kernelspecs` | cookie | `{ default, specs: [{ name, displayName, language }] }`. Starts the local jupyter server on demand; 503 `{error, hint?}` when disabled / unavailable / it fails to start |
101
102
  | 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 }` |
@@ -103,7 +104,7 @@ Installed extensions are directories under `~/.halo/global/extensions/<id>/` wit
103
104
  | DELETE | `/api/jupyter/kernels/:id` | cookie | Shut the kernel down → `{ ok: true }`; 404 for an unknown id |
104
105
  | 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) |
105
106
 
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).
107
+ Extension capabilities the host understands (`ExtensionCapability`): `save`, `media`, `transcribe`, `fs-read`, `stt-local` (may open `/api/stt/stream`; an installed one also starts the model download — [design/stt-local.md](../design/stt-local.md)); an unknown value makes the manifest an error. An optional manifest `activity` object (`icon`, `title[_zh]`, `description[_zh]`, `order`) arrives as `ExtensionInfo.activity` and gives the extension an admin activity-bar page; an optional `chatbar` object (`icon`, `title[_zh]`, `height`, `order`) arrives as `ExtensionInfo.chatbar` and gives it a chat-input toolbar button whose panel may `insert-text` into the draft and report `busy` to get a `close-request` before a user close unmounts it (no route of its own); with either, `extensions` may be `[]` (the icon is a normal asset served by the route above, `.svg` as `image/svg+xml`) — rules in [design/canvas-extensions.md](../design/canvas-extensions.md#manifest-halo-extensionjson). `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).
107
108
 
108
109
  **Why the asset route isn't cookie-authed.** The admin first hosted extensions in `<iframe sandbox="allow-scripts">` without `allow-same-origin` — an opaque origin. The document navigation still carries the cookie, but every subresource it loads (classic/module scripts, img, css, fetch, wasm, dynamic import, Worker) is a cross-site request with **no cookie**, and module/fetch/wasm need `Access-Control-Allow-Origin` too. A `?token=` query would be dropped when the document resolves `./viewer.js`-style relative URLs, so the token is a **path segment** that every relative URL inherits. The host now grants `allow-same-origin` (a cookie-auth proxy in front of halo — CloudFront + midway, oauth2-proxy — needs its *own* cookie on those requests, which only a same-origin iframe sends; see [design/canvas-extensions.md](../design/canvas-extensions.md#asset-serving-and-the-scoped-token)), so the admin cookie does travel again, but the route keeps the path token as its single auth path. `authMiddleware` lets exactly that path shape (`EXTENSION_ASSET_PATH = /^\/api\/extensions\/[^/]+\/[^/]+\/[^/]+\/./`) through without a cookie and the route verifies the token itself. The token is good for nothing else: `validateToken` (admin cookie / WS upgrade) refuses any JWT payload that carries a `scope`, so an asset token replayed as `halo_token` is a 401.
109
110
 
@@ -218,7 +219,7 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
218
219
  | 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) |
219
220
  | 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}` |
220
221
  | 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}` |
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}` |
222
+ | DELETE | `/api/cron/jobs/:id` | Unschedule, delete each stateless run's own session (except running / user-continued ones) and the job's `cron_runs` rows, then delete the job. A run still in progress keeps its row and session until it finalizes, then both go ([cron.md → Retention](../design/cron.md#retention)). Returns `{ok}` |
222
223
  | POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget). Returns `{ok}` |
223
224
  | POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
224
225
 
@@ -333,7 +334,7 @@ Files: `packages/server/src/a2a/routes.ts` (JSON-RPC handler), `exposure.ts` (pa
333
334
  | Method | Path | Purpose |
334
335
  |---|---|---|
335
336
  | POST | `/a2a/<rel>` (trailing `/` ok) | JSON-RPC 2.0 for the workspace `<A2A base>/<rel>` |
336
- | GET | `/a2a/<rel>/.well-known/agent-card.json` | The agent card. `Cache-Control: private, max-age=300` + `ETag` (304 on `If-None-Match`); invalid `.halo/agent-card.json` → 500 |
337
+ | GET | `/a2a/<rel>/.well-known/agent-card.json` | The agent card. `Cache-Control: private, max-age=300` + weak `ETag` (`W/"…"`, the same on a 200 gzipped or not and on its 304; 304 on a weak `If-None-Match` match); invalid `.halo/agent-card.json` → 500 |
337
338
  | POST | `/a2a-push/:pushId` | Webhook receiver for remotes this server dispatched to (`a2a_send`) |
338
339
 
339
340
  **Exposure**: `<rel>` is the workspace path relative to the base dir (`HALO_A2A_ROOT`, default the user's home), each segment percent-encoded. Only a workspace containing `.halo/agent-card.json` is served; empty / `.` / `..` segments, separators inside a segment, and symlinks leaving the base are refused. The card's interface URL is `general.a2a.public_url` (env `HALO_A2A_PUBLIC_URL`) + `/a2a/<rel>/`, else derived from the request host.
@@ -382,20 +383,22 @@ The push is a doorbell: only the task id is read from the body. The receiver the
382
383
 
383
384
  ## Show (world snapshot)
384
385
 
385
- File: `packages/server/src/routes/halo-city.ts`. Token auth (same `x-token` as
386
- the Web channel — same shared `resolveTokenAuth` core, same JSON error shape,
387
- same brute-force lockout bucket). Read-only, cross-workspace
386
+ File: `packages/server/src/routes/halo-city.ts`. Read-only, cross-workspace
388
387
  snapshot powering the `halo-city` pixel visualizer — one call returns the whole
389
388
  runtime so the frontend can render rooms (workspaces) + characters (sessions).
390
389
 
390
+ **Auth** — either credential; both paths stay in `PUBLIC_PATHS` and the router authenticates itself:
391
+ - **Token** (`x-token` header or `?token=`, same as the Web channel — the shared `resolveTokenAuth` core, same JSON error shape, same brute-force lockout bucket). A request that carries a token is judged by the token alone, even when an admin cookie is also present (bad token + good cookie → 401 `invalid token`, counted as a strike).
392
+ - **Admin login cookie** (`halo_token`, the same check as the WS upgrade), used only when no token is sent: treated as `full` — every workspace, any session, `accessLevel: 'full'` in `/show/state`. This is how the halo-city extension page inside the admin calls these routes without a token. An invalid / expired cookie is just "no credential" (401 `token required`) and never counts as a strike.
393
+
391
394
  | Method | Path | Purpose |
392
395
  |---|---|---|
393
- | GET | `/api/show/state` | `full` or `observer` token → every known workspace; otherwise the account's own. Returns `{ serverTime, uptime, accessLevel, skills[], workspaces[] }` |
394
- | GET | `/api/show/session?ws=&id=` | Inspector-panel detail for a single session. Trimmed message log (last 40, content/tool I/O capped) plus `totalMessages` / `contextTokens` / `outputTokens` / `maxContextTokens` / `isRunning`. Non-`full`/`observer` tokens may only read their own workspace, and within it only sessions their own account minted (`web_<accountId>_*`); anything else is 403. |
396
+ | GET | `/api/show/state` | Admin cookie, `full` or `observer` token → every known workspace; otherwise the account's own. Returns `{ serverTime, uptime, accessLevel, skills[], workspaces[] }`. Sends a weak `ETag` (over `accessLevel` / `skills` / `workspaces` plus `uptime` by the minute — never `serverTime`) and `Cache-Control: private, no-cache`; a weakly-matching `If-None-Match` (list or `*`) gets an empty 304 after auth, so an idle server answers 200 at most once a minute. On a 304 the cached copy's `serverTime` / `uptime` are those of the last 200. |
397
+ | GET | `/api/show/session?ws=&id=` | Inspector-panel detail for a single session. Trimmed message log (last 40, content/tool I/O capped) plus `totalMessages` / `contextTokens` / `outputTokens` / `maxContextTokens` / `isRunning`. Tokens other than `full`/`observer` may only read their own workspace, and within it only sessions their own account minted (`web_<accountId>_*`); anything else is 403. The admin cookie reads any workspace. |
395
398
 
396
399
  `observer` is more than an aggregate-counts role: past `/show/state`, it can also call `/show/session` to read **any workspace's** session transcript (last 40 messages, content truncated to 600 chars, tool input to 200 chars). Mint it knowing it grants cross-workspace transcript read access, not just dashboard counters.
397
400
 
398
- Each `workspace` = `{ path, key, label, counts{running,idle,stopped}, totalSessions, skills[], sessions[] }`; each `session` = `{ id, parentId, depth, agentName, description, status, lastTool, activeSkill, contextTokens, outputTokens, messageCount, updatedAt }`. `messageCount` here is the session file's raw UI-log length (an activity signal for the citizen animation) — deliberately **not** the admin list's lifetime `exchangeCount`; it shrinks when a compact archives history. `lastTool` / `activeSkill` come from the live in-memory UI log (empty when the session isn't loaded). Sessions per room are capped (`totalSessions` reports the true total). Frontend: `halo-city/` at repo root.
401
+ Each `workspace` = `{ path, key, label, counts{running,idle,stopped}, totalSessions, skills[], sessions[] }`; each `session` = `{ id, parentId, depth, agentName, description, status, lastTool, activeSkill, contextTokens, outputTokens, messageCount, updatedAt }`. `messageCount` here is the session file's raw UI-log length (an activity signal for the citizen animation) — deliberately **not** the admin list's lifetime `exchangeCount`; it shrinks when a compact archives history. `lastTool` / `activeSkill` come from the live in-memory UI log (empty when the session isn't loaded). Sessions per room are capped (`totalSessions` reports the true total). Frontend: the halo-hub extension [`halo-city`](https://github.com/turmind/halo-hub/tree/main/extensions/halo-city).
399
402
 
400
403
  **Read-only by construction — degraded snapshots.** Both endpoints resolve a workspace's runtime via `registry.peek()` — in-memory lookup only, **never** `getOrCreate`: constructing a SessionManager has write side effects (boot orphan-reconciliation batch-stops live sub-session rows; `.halo/` scaffolding), so a pure visualization poll must not trigger it. When no live SessionManager exists in this process, the handler opens that workspace's `.halo/halo.db` **read-only** (cached connection; missing db/tables silently degrade to an empty room) and projects rows / the persisted session file into the same wire shape. Sessions not in memory therefore return a **persisted snapshot**: `status` / token counts / `messageCount` may lag behind the last persistence point, and the live-only signals (`lastTool` / `activeSkill`) are empty by definition. The degraded `/api/show/session` path reports `maxContextTokens: 0` (the real cap needs a live agent config; the city inspector hides the meter when the cap is unknown).
401
404