@turmind/halo 1.5.4 → 1.5.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 (84) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/{133-33aa091fba853037.js → 422-cc74b1fdd3af5415.js} +1 -1
  3. package/admin-out/_next/static/chunks/989-e0faeff1c4a0b0b9.js +1 -0
  4. package/admin-out/_next/static/chunks/app/page-44f707ea8dad57da.js +1 -0
  5. package/admin-out/_next/static/chunks/{webpack-1a264efd965db7ab.js → webpack-edf248e07333566d.js} +1 -1
  6. package/admin-out/_next/static/css/b8c87b919758c52b.css +3 -0
  7. package/admin-out/index.html +1 -1
  8. package/admin-out/index.txt +5 -5
  9. package/bundled-docs/design/agent.md +10 -8
  10. package/bundled-docs/design/agentcore.md +13 -10
  11. package/bundled-docs/design/architecture.md +24 -14
  12. package/bundled-docs/design/canvas-extensions.md +19 -4
  13. package/bundled-docs/design/command.md +5 -5
  14. package/bundled-docs/design/cron.md +2 -2
  15. package/bundled-docs/design/evolution.md +34 -35
  16. package/bundled-docs/design/express-self.md +6 -4
  17. package/bundled-docs/design/feishu.md +18 -14
  18. package/bundled-docs/design/halo-city.md +5 -4
  19. package/bundled-docs/design/observability.md +9 -9
  20. package/bundled-docs/design/prompt-system.md +12 -12
  21. package/bundled-docs/design/session.md +14 -14
  22. package/bundled-docs/design/slack.md +22 -20
  23. package/bundled-docs/design/storage.md +30 -29
  24. package/bundled-docs/design/telegram.md +26 -8
  25. package/bundled-docs/design/web.md +3 -1
  26. package/bundled-docs/design/wechat.md +8 -6
  27. package/bundled-docs/design/wecom.md +23 -9
  28. package/bundled-docs/design/ws.md +6 -6
  29. package/bundled-docs/dev/acp-adapter.md +8 -8
  30. package/bundled-docs/dev/add-channel.md +12 -9
  31. package/bundled-docs/dev/add-model-provider.md +31 -20
  32. package/bundled-docs/dev/api.md +86 -57
  33. package/bundled-docs/dev/deploy.md +8 -6
  34. package/bundled-docs/dev/env.md +29 -21
  35. package/bundled-docs/dev/logging.md +15 -29
  36. package/bundled-docs/dev/previews.md +20 -11
  37. package/bundled-docs/dev/tools.md +26 -14
  38. package/bundled-docs/guide/README.md +1 -0
  39. package/bundled-docs/guide/agents.md +19 -53
  40. package/bundled-docs/guide/channels/README.md +7 -4
  41. package/bundled-docs/guide/channels/acp.md +3 -1
  42. package/bundled-docs/guide/channels/feishu.md +13 -11
  43. package/bundled-docs/guide/channels/slack.md +12 -10
  44. package/bundled-docs/guide/channels/telegram.md +21 -27
  45. package/bundled-docs/guide/channels/web.md +15 -11
  46. package/bundled-docs/guide/channels/wechat.md +15 -14
  47. package/bundled-docs/guide/channels/wecom.md +7 -2
  48. package/bundled-docs/guide/chat.md +10 -9
  49. package/bundled-docs/guide/cli.md +10 -8
  50. package/bundled-docs/guide/delegation-and-access.md +1 -1
  51. package/bundled-docs/guide/getting-started.md +3 -3
  52. package/bundled-docs/guide/secrets-and-credentials.md +3 -3
  53. package/bundled-docs/guide/sessions.md +3 -3
  54. package/bundled-docs/guide/skills.md +9 -7
  55. package/bundled-docs/guide/testing-agents-and-skills.md +14 -15
  56. package/bundled-docs/guide/workspace.md +29 -17
  57. package/bundled-docs/requirements/agent.md +19 -18
  58. package/bundled-docs/requirements/chat.md +16 -17
  59. package/bundled-docs/requirements/command.md +10 -10
  60. package/bundled-docs/requirements/editor.md +12 -10
  61. package/bundled-docs/requirements/explorer.md +10 -7
  62. package/bundled-docs/requirements/session.md +16 -17
  63. package/bundled-docs/requirements/settings.md +30 -18
  64. package/bundled-docs/requirements/skill.md +4 -4
  65. package/bundled-docs/requirements/source-control.md +4 -3
  66. package/bundled-docs/requirements/terminal.md +13 -13
  67. package/bundled-docs/requirements/workspace.md +19 -13
  68. package/dist/index.js +10587 -12088
  69. package/package.json +1 -1
  70. package/templates/agents/default/agent.yaml +1 -0
  71. package/templates/config.yaml +6 -0
  72. package/templates/skills/extension/SKILL.md +25 -14
  73. package/templates/skills/extension/config.yaml +9 -0
  74. package/templates/skills/extension/templates/ext.sh +166 -34
  75. package/templates/skills/web-search/SKILL.md +20 -6
  76. package/templates/skills/web-search/config.yaml +13 -0
  77. package/templates/skills/web-search/search.py +27 -6
  78. package/templates/skills/workspace/share.md +11 -3
  79. package/templates/skills/workspace/stage.py +85 -5
  80. package/admin-out/_next/static/chunks/989-92e9683ae8d39750.js +0 -1
  81. package/admin-out/_next/static/chunks/app/page-7a635969198fc62b.js +0 -1
  82. package/admin-out/_next/static/css/4ea5f674146e54d8.css +0 -3
  83. /package/admin-out/_next/static/{lm8Bz3ugTSORyuSXfyx56 → visjvN510yFK-Ff5zwx-0}/_buildManifest.js +0 -0
  84. /package/admin-out/_next/static/{lm8Bz3ugTSORyuSXfyx56 → visjvN510yFK-Ff5zwx-0}/_ssgManifest.js +0 -0
@@ -65,7 +65,7 @@ Halo 服务器运行态的**街景式**像素可视化:侧视、剖面的像素
65
65
  | 🛸 UFO | 每天 0:00 | 午夜从街头飘过 |
66
66
  | ✈️ 飞机 | 每半点(`:30`) | 高空掠过,方向随机 |
67
67
  | 🚌 巴士 | 每 3 分钟 | 进站、开门、接走在站台等车的人,再开走 |
68
- | 🚗 进出场载具 | 跟随会话 | 新会话打车/电动车/跑车送到门口;结束的会话走到站台**体面地等车离场**,不再凭空消失 |
68
+ | 🚗 进出场载具 | 跟随会话 | 新根会话坐出租车或小轿车送到门口(子代理直接淡入,不配车);结束的会话走到站台**体面地等车离场**,不再凭空消失 |
69
69
  | 🏗️ 盖楼 / 拆楼 | 跟随楼层增减 | 长楼层有施工动效;拆楼留 ~30s 缓冲(先让人撤离再慢慢消散,不穿帮) |
70
70
 
71
71
  > 定时全用真实 `Date.now()` 计算(`?hour=` 只冻结天色、不冻结这些事件)。
@@ -73,8 +73,8 @@ Halo 服务器运行态的**街景式**像素可视化:侧视、剖面的像素
73
73
  ## 交互与显示
74
74
 
75
75
  操作:滚轮缩放(九档 `0.3×–6×`)· 拖拽平移 · 点击查看 · **双击**在 1×/3× 间快速切 ·
76
- **F** 全街区 · **H**(或点左上角 logo)切纯净模式 · **Esc** 逐层退(先关面板,再退纯净)·
77
- 右上角 **中/EN** 一键切语言。闲置 10s 后镜头沿街慢慢巡游。
76
+ **F** 全街区 · **H**(或点左上角 logo)切纯净模式 · **Esc** 先关面板,没有面板时切纯净模式 ·
77
+ 右上角 **中/EN** 一键切语言。
78
78
 
79
79
  - **纯净模式(zen)**:淡出所有 HUD(统计/动态/图例/缩放条/面板),只留街景,适合大屏常驻展示
80
80
  - **多语言(i18n)**:中文 / English 一键切;`?lang=en`/`?lang=zh` 直达,选择存 localStorage。
@@ -104,6 +104,7 @@ js/
104
104
  camera.js 整数吸附镜头(九档缩放 0.3×–6×)
105
105
  inspector.js 面板 + 实时会话日志查看器
106
106
  ticker.js 街区动态 feed
107
+ music.js 背景音乐:纯 Web Audio 程序生成
107
108
  traffic.js 街景事件:UFO(0点)/飞机(每半点)/进出场载具/每 3 分钟 bus/盖楼·拆楼
108
109
  i18n.js 多语言词典(zh/en)+ 运行时 t() + 静态 data-i18n 填充
109
110
  util.js hash/PRNG/格式化
@@ -117,7 +118,7 @@ js/
117
118
  token 铸造的会话**(id 以 `web_<accountId>_` 开头);点其它市民会 403,
118
119
  前端在 inspector 里显示加载失败框而不是 transcript
119
120
  - `/api/show/state` — 无活跃 UIState 的会话(idle/stopped/重启后)token
120
- 从会话文件头读取(mtime 缓存,不随轮询刷盘),不显示 0;含 `messageCount` 字段
121
+ 从会话文件头读取(按会话 `updatedAt` 缓存,未变化时不随轮询重读),不显示 0;含 `messageCount` 字段
121
122
 
122
123
  > **`messageCount` 与 admin 的 `exchangeCount` 是两个语义,不要互相对齐**:
123
124
  > 这里读的是会话文件里的 `messageCount`(活跃 UI 日志的长度),城市只把它
@@ -34,14 +34,14 @@ No `OTEL_EXPORTER_OTLP_ENDPOINT` after the mapping → `enabled=false`, `initObs
34
34
 
35
35
  | module | role |
36
36
  |---|---|
37
- | `packages/server/src/observability/otel.ts` (91 lines) | Bootstrap. `initObservability()` / `shutdownObservability()`, the `enabled` gate, `tracer` / `getMeter()` / `otelLogger` / `captureContent()` exports. |
38
- | `packages/server/src/observability/otel-sdk.ts` (47 lines) | The ONE module that imports the SDK packages and OTLP exporters. |
39
- | `packages/server/src/observability/genai-spans.ts` (296 lines) | The span model: `beginTurn` / `onAgentEvent` / `recordRetry` / `endTurn`. |
37
+ | `packages/server/src/observability/otel.ts` | Bootstrap. `initObservability()` / `shutdownObservability()`, the `enabled` gate, `tracer` / `getMeter()` / `otelLogger` / `captureContent()` exports. |
38
+ | `packages/server/src/observability/otel-sdk.ts` | The ONE module that imports the SDK packages and OTLP exporters. |
39
+ | `packages/server/src/observability/genai-spans.ts` | The span model: `beginTurn` / `onAgentEvent` / `recordRetry` / `endTurn`. |
40
40
  | `packages/server/src/agents/session-manager.ts` | Calls the four hooks above from the turn loop (see below). |
41
41
  | `packages/server/src/logger.ts` | Bridges every `logger.*` call into an OTel LogRecord when `enabled`. |
42
- | `packages/server/src/index.ts` | `await initObservability()` (L262, before `initLogger()`) and `await shutdownObservability()` (L599, in `gracefulShutdown`). |
42
+ | `packages/server/src/index.ts` | `await initObservability()` (before `initLogger()`) and `await shutdownObservability()` (in `gracefulShutdown`). |
43
43
 
44
- **Hook call sites in `session-manager.ts`**: `beginTurn(session, message)` at L1276 (start of `runAgentTurn`), `onAgentEvent(session, event)` at L1316 (inside the agent event loop, one call per `AgentEvent`), `recordRetry(kind)` at the 7 retry catch sites, `endTurn(session, {error?})` at L1547 (the turn's single exit point, success or failure).
44
+ **Hook call sites in `session-manager.ts`** (all in `runAgentTurn`): `beginTurn(session, message)` at the start, `onAgentEvent(session, event)` inside the agent event loop (one call per `AgentEvent`), `recordRetry(kind)` at the 7 retry catch sites, `endTurn(session, {error?})` once after the retry loop (the turn's single exit point, success or failure).
45
45
 
46
46
  **The `enabled` gate.** `otel.ts` exports a single `enabled: boolean`, set once by `initObservability()`. Every hook in `genai-spans.ts` and the `logger.ts` bridge checks it first — an unconfigured server pays nothing beyond one boolean read per hook call.
47
47
 
@@ -49,9 +49,9 @@ No `OTEL_EXPORTER_OTLP_ENDPOINT` after the mapping → `enabled=false`, `initObs
49
49
 
50
50
  **Why the meter is lazy.** The `@opentelemetry/api` tracer and logger are proxies — they stay no-op until a real provider is registered, then transparently start forwarding, so `tracer` / `otelLogger` can be module-level consts. The metrics API has **no proxy**: a `Meter` fetched via `metrics.getMeter()` before `setGlobalMeterProvider()` runs is a permanent no-op forever after, even once a provider registers later. `getMeter()` in `otel.ts` is therefore a function, not a const, and `genai-spans.ts` calls it lazily on first metric emission (`instruments()`, cached after).
51
51
 
52
- **Dependencies** (`packages/server/package.json`): `@opentelemetry/api` 1.9.1, `api-logs` 0.222.0, `sdk-trace-node` / `sdk-trace-base` / `sdk-metrics` / `sdk-logs` / `resources` 2.11.0, `exporter-trace-otlp-proto` / `exporter-metrics-otlp-proto` / `exporter-logs-otlp-proto` 0.222.0, `semantic-conventions` 1.43.0. No gRPC exporter (see [Rejected alternatives](#rejected-alternatives)).
52
+ **Dependencies** (`packages/server/package.json`): `@opentelemetry/api` 1.9.1, `api-logs` 0.222.0, `sdk-trace-node` / `sdk-trace-base` / `sdk-metrics` / `resources` 2.11.0, `sdk-logs` / `exporter-trace-otlp-proto` / `exporter-metrics-otlp-proto` / `exporter-logs-otlp-proto` 0.222.0, `semantic-conventions` 1.43.0. No gRPC exporter (see [Rejected alternatives](#rejected-alternatives)).
53
53
 
54
- **Test coverage**: `packages/server/test/observability-genai-spans.test.ts` (7 cases) drives `beginTurn` / `onAgentEvent` / `endTurn` against an in-memory tracer + `InMemorySpanExporter`, asserting the three-level span tree, parent ids, semconv attribute names, and the `capture_content` on/off split.
54
+ **Test coverage**: `packages/server/test/observability-genai-spans.test.ts` (8 cases) drives `beginTurn` / `onAgentEvent` / `endTurn` against an in-memory tracer + `InMemorySpanExporter`, asserting the three-level span tree, parent ids, semconv attribute names, and the `capture_content` on/off split.
55
55
 
56
56
  ## Span model
57
57
 
@@ -87,7 +87,7 @@ Child of `invoke_agent`, one per model call — created retroactively on the `us
87
87
  | `gen_ai.input.messages` | | semconv JSON (see below) — **delta only**: messages appended since this turn's previous `chat` span (first call → the user message; later calls → that cycle's `tool_result`s) |
88
88
  | `gen_ai.output.messages` | | semconv JSON, only the trailing assistant message |
89
89
 
90
- Semconv message JSON: `[{role, parts:[{type:"text",content}|{type:"tool_call",id,name,arguments}|{type:"tool_call_response",id,result}]}]`.
90
+ Semconv message JSON: `[{role, parts:[{type:"text",content}|{type:"tool_call",id,name,arguments}|{type:"tool_call_response",id,response}]}]`.
91
91
 
92
92
  `gen_ai.input.messages` is incremental on purpose (since 1.3.1): replaying the full history on every model call made a turn's exported bytes O(n²) and the system prompt alone was ~60% of the volume — a 4-turn / 9-call session dropped from 129 KB to 44 KB of content attributes. Nothing is lost: the full conversation for a trace is the concatenation of its `chat` spans' input + output deltas in order (a `TurnState.messageCursor` tracks the boundary; a mid-turn compact that shrinks the history yields an empty delta and re-syncs the cursor). Verified that AgentCore Evaluations scores are unchanged — its evaluators read `invoke_agent`'s `task.*` and `execute_tool`'s `arguments/result`, not the chat messages.
93
93
 
@@ -105,7 +105,7 @@ Child of the `chat` span that requested it, one per tool call — created on the
105
105
  | `gen_ai.tool.call.arguments` | | JSON |
106
106
  | `gen_ai.tool.call.result` | | text, empty → `[no output]` |
107
107
 
108
- Tool calls still pending when `endTurn` fires (turn interrupted/aborted mid-cycle) are closed as orphans with status `ERROR` and `halo.tool.orphaned=true`, so no trace is left with a dangling tool-call id.
108
+ Tool calls still pending when `endTurn` fires (turn interrupted/aborted mid-cycle) are closed as orphans (no status set) with `halo.tool.orphaned=true`, so no trace is left with a dangling tool-call id.
109
109
 
110
110
  All content attributes are capped (32 KB general cap, 2 KB per message part, 8 KB for system instructions); over-cap content is truncated with a `…[truncated N chars]` marker, and an over-cap message list drops the oldest messages first with an omission marker.
111
111
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  On agent startup, Halo assembles the system prompt by concatenating various MD files in a fixed order.
4
4
 
5
- Entry point: `session-manager.ts` `buildAgentInstance(agentId, sessionId, parentId?, workingDir?, accessLevel?)`
5
+ Entry point: `session-agent-builder.ts` `SessionAgentBuilder.buildAgentInstance(agentId, sessionId, parentId?, workingDir?, accessLevel?)`
6
6
 
7
7
  **Root agent rule**: `!parentId` (parentId null/undefined = root). Does not depend on any `is_default` field.
8
8
 
@@ -17,7 +17,7 @@ Entry point: `session-manager.ts` `buildAgentInstance(agentId, sessionId, parent
17
17
  ├── prompts/ ← user-editable system prompts (externalised)
18
18
  │ ├── bootstrap/BOOTSTRAP.md ← first-run guidance
19
19
  │ ├── all/ ← every-agent rules (TOOL_GUIDELINES.md, TOOL_SHELL[.windows].md, WORKSPACE_CONVENTIONS.md, RUNTIME.md)
20
- │ └── root/ ← root-agent-only (empty by default; user-set)
20
+ │ └── root/ ← root-agent-only (DELEGATION.md, WORKSPACE_MEMORY.md)
21
21
  ├── agents/<id>/{agent.yaml, AGENT.md}
22
22
  └── skills/<id>/SKILL.md ← built-in `halo` skill carries platform self-knowledge (loaded on demand via activate_skill)
23
23
  ```
@@ -52,7 +52,7 @@ Resolution is by the agent's **folder** (`agentSourceDir`), not per file:
52
52
  agent entirely (both `agent.yaml` and `AGENT.md`), with no per-file fallback
53
53
  to global — a missing file inside it is just absent. YAML fields: `name` /
54
54
  `description` / `model` / `tools` / `skills` / `system_prompt` / `context` /
55
- `priority`.
55
+ `priority` / `internal` / `team`.
56
56
 
57
57
  ## Step 2 — Resolve MD file paths
58
58
 
@@ -127,14 +127,14 @@ Startup seeds `templates/prompts/{bootstrap,all,root}/` into `~/.halo/global/pro
127
127
 
128
128
  Replacement is **whole-file**, not per-section — so split the platform-divergent content into its own file and keep the common content in an un-suffixed file. Example in `prompts/all/`: `TOOL_GUIDELINES.md` (common) + `TOOL_SHELL.md` (unix Shell section) / `TOOL_SHELL.windows.md` (cmd.exe, `dir`/`findstr`, `%USERPROFILE%`, `python` vs `python3`, `.py` skill caveats). To add a new platform difference anywhere, extract that block to `FOO.md` and add `FOO.windows.md`.
129
129
 
130
- Result:
131
- - `prompts/bootstrap/*.md` → `bootstrapPrompt`
132
- - `prompts/all/*.md` → `allPrompt`
133
- - `prompts/root/*.md` → `rootPrompt`
130
+ Result (the returned `systemPrompts` object; the sections below write these fields as `bootstrapPrompt` / `allPrompt` / `rootPrompt`):
131
+ - `prompts/bootstrap/*.md` → `systemPrompts.bootstrap`
132
+ - `prompts/all/*.md` → `systemPrompts.all`
133
+ - `prompts/root/*.md` → `systemPrompts.root`
134
134
 
135
135
  Missing directory or read failure: warn + use built-in fallback.
136
136
 
137
- `loadSystemPrompts` also returns `files: { bootstrap: string[]; all: string[]; root: string[] }` — the absolute paths of the `.md` files actually read for each scope (empty when the built-in fallback was used). `session-manager.ts` forwards this list into `AgentMeta.mdFiles`, which `/session context` renders one line per file (label `prompt/<scope>/<basename>`). When a scope falls back to the built-in constants, a single line `prompt/<scope> (built-in fallback): <dir>` is shown instead.
137
+ `loadSystemPrompts` also returns `files: { bootstrap: string[]; all: string[]; root: string[] }` — the absolute paths of the `.md` files actually read for each scope (empty when the built-in fallback was used). `session-agent-builder.ts` forwards this list into `AgentMeta.mdFiles`, which `/session context` renders one line per file (label `prompt/<scope>/<basename>`). When a scope falls back to the built-in constants, a single line `prompt/<scope> (built-in fallback): <dir>` is shown instead.
138
138
 
139
139
  ## Step 5 — Compose the MD prompt
140
140
 
@@ -159,8 +159,8 @@ Global and workspace-root INSTRUCTIONS share the same `## User Instructions` hea
159
159
  mdPrompt ← incl. roster + working_dir scope (both inside composeMdPrompt)
160
160
  + "\n\nThe project workspace is at: {workspaceRoot}\n"
161
161
  + [optional] "Working directory: {workingDir}\n"
162
- + allPrompt
163
162
  + rootPrompt
163
+ + allPrompt
164
164
  ```
165
165
 
166
166
  (The `working_dir` directory-chain INSTRUCTIONS.md live *inside* `mdPrompt` — folded into the `## User Instructions` region by `composeMdPrompt` — not after the `Working directory:` tagline. That tagline is just a one-line focus marker.)
@@ -216,7 +216,7 @@ If `mdPrompt` is empty:
216
216
 
217
217
  ## Step 7 — Append skills and tool list
218
218
 
219
- [session-manager.ts:682-697](../../../packages/server/src/agents/session-manager.ts#L682)
219
+ [session-agent-builder.ts](../../../packages/server/src/agents/session-agent-builder.ts) `composeSystemPrompt`
220
220
 
221
221
  ### Skills (progressive disclosure)
222
222
 
@@ -245,7 +245,7 @@ AGENT.md ← workspace > global
245
245
  ## Your Team ← roster (dropped when team is empty/unset)
246
246
  ## User Instructions ← ~/.halo/global/INSTRUCTIONS.md (suppressed when ws has its own)
247
247
  ## User Instructions ← <ws>/.halo/INSTRUCTIONS.md (workspace root)
248
- ## Project Knowledge ← <ws>/.halo/INDEX.md (or nudge)
248
+ ## Project Knowledge ← <ws>/.halo/INDEX.md (skipped when absent)
249
249
  "The project workspace is at: ..."
250
250
  "Working directory: ..." ← if workingDir is set
251
251
  rootPrompt ← prompts/root/*.md (ws > global): DELEGATION.md, WORKSPACE_MEMORY.md
@@ -403,4 +403,4 @@ Schema declared by each package + values stored centrally is the same model VSCo
403
403
  - **Bootstrap trigger**: `!parentId && !userMd`
404
404
  - **System prompts are external**: resolved from workspace then global `prompts/{bootstrap,all,root}/*.md`, read live, sorted by filename.
405
405
  - **System prompt missing**: warn + use built-in fallback; `system-prompts.ts` is the seed source and fallback
406
- - **`.halo/` is not grep/globbed**: use `file_read` + INDEX.md navigation
406
+ - **`.halo/` is grep/globbed** except its runtime subtrees (`sessions/ logs/ evo/ tmp/ assets/ canvas/`)
@@ -82,9 +82,9 @@ Manages every agent session's lifecycle (root + sub-agent). Each session is 1:1
82
82
 
83
83
  | Method | Purpose |
84
84
  |---|---|
85
- | `createSession(agentId, parentId, description, agentName?, explicitId?, workingDir?, accessLevel?)` | Create a session (SQLite + memory). `workingDir` = absolute path at runtime, stored as workspace-relative in DB; null = project root. `accessLevel` = `'readonly'`, `'workspace'`, or `null` (full). |
86
- | `sendUserMessage(sessionId, text, images?)` | Send a message — run immediately if idle, queue if busy |
87
- | `compactSession(sessionId)` | LLM-summary compact |
85
+ | `createSession(agentId, parentId, description, agentName?, explicitId?, workingDir?, accessLevel?, title?)` | Create a session (SQLite + memory). `workingDir` = absolute path at runtime, stored as workspace-relative in DB; null = project root. `accessLevel` = `'readonly'`, `'workspace'`, or `null` (full). |
86
+ | `sendUserMessage(sessionId, text, images?, accessLevel?)` | Send a message — run immediately if idle, queue if busy; returns `'running'` or `'queued'` |
87
+ | `compactSession(sessionId, opts?)` | LLM-summary compact (`opts.onProgress` for UI progress) |
88
88
  | `interruptSession(sessionId)` | Abort the in-flight turn now (fire-and-forget); `interruptRequested` is set so the unwind repairs rather than errors, then the queued message drains. Shared by esc and the `interrupt_session` tool |
89
89
  | `stopSession(sessionId)` | Fold the whole `messageQueue` into `agent.messages` (preserve, don't drop), abort + repair, no re-run, sets `stoppedAt`. Cascades to descendants |
90
90
  | `deleteSession(sessionId)` | Cascade-delete a session and all descendants (SQLite) |
@@ -166,7 +166,7 @@ Hierarchical encoding: `root_id>child_segment>grandchild_segment`.
166
166
 
167
167
  A sub-session whose process was killed mid-run never got its `stoppedAt` written, so it stays `stoppedAt IS NULL` forever — displaying as a false "running" and permanently blocking its parent's auto-report bubbling (`tryReportToParent` sees a "live" child that will never report back). When the server process first builds a workspace's SessionManager, `reconcileOrphansOnBoot` batch-stamps `stoppedAt` on every non-root, non-stopped, non-archived session. Only the long-lived server passes `reconcileOrphansOnBoot: true` through the registry — CLI/TUI/channel-subprocess/evo-wrapper share the same db while the server may be running sessions, so they never reconcile. If an orphan is later revived via `query_session`, that path clears `stoppedAt` again, so nothing is trapped permanently.
168
168
 
169
- **Workspace-level gate (`.halo/runtime.lock`)**: `server.lock` ownership alone is not sufficient — two servers with different `HALO_HOME` (e.g. prod + dev) each hold their own `server.lock` yet can point at the *same* workspace directory, and one server's boot reconcile would batch-stop the other's actually-live sub-sessions (the incident this gate exists for). So the reconcile additionally requires `claimWorkspaceRuntime(workspaceRoot)` (`agents/workspace-runtime-lock.ts`) — a pid marker at `<workspace>/.halo/runtime.lock` with a liveness probe. Claim fails → skip reconcile and log a warning: **prefer missing a crash-orphan cleanup over stopping another process's live sessions**. `reconcileOrphansOnBoot: true` therefore means "reconcile if the workspace claim succeeds," not "always." Lock protocol details in [storage.md](storage.md#workspace-runtime-lock); known residual: when two servers both actively use one workspace long-term, the non-owner never reconciles — its own crash orphans stay un-cleaned until the owner restarts and takes over. The run-ledger eager boot loop below deals with the *un-opened* case for the same residual: a workspace whose runtime another live process owns is skipped before a SessionManager is even built, so the rows wait for a later boot instead of freezing behind a cached non-owner instance.
169
+ **Workspace-level gate (`.halo/runtime.lock`)**: `server.lock` ownership alone is not sufficient — two servers with different `HALO_HOME` (e.g. prod + dev) each hold their own `server.lock` yet can point at the *same* workspace directory, and one server's boot reconcile would batch-stop the other's actually-live sub-sessions (the incident this gate exists for). So the reconcile additionally requires `claimWorkspaceRuntime(workspaceRoot)` (`agents/workspace-runtime-lock.ts`) — a pid marker at `<workspace>/.halo/runtime.lock` with a liveness probe. Claim fails → skip reconcile and log a warning: **prefer missing a crash-orphan cleanup over stopping another process's live sessions**. `reconcileOrphansOnBoot: true` therefore means "reconcile if the workspace claim succeeds," not "always." Lock protocol details in [storage.md](storage.md#workspace-runtime-lock); known residual: when two servers both actively use one workspace long-term, the non-owner never reconciles — its own crash orphans stay un-cleaned until the owner restarts and takes over. The run-ledger eager boot loop below deals with the *un-opened* case for the same residual: a workspace whose runtime another live process owns is skipped before a SessionManager is even built, so the rows wait for a later boot instead of freezing behind a cached non-owner instance. A `HALO_BADGE=DEV` server never takes part (`config.server.ownsWorkspaceRuntimes`): its registry is built with `reconcileOrphansOnBoot: false` and it skips the eager loop, so it never claims or writes `runtime.lock` and never runs reconcile, goal or ledger sweeps. Its ledger is off as well. Channel route restore builds its SessionManager without a claim. Prod owns all of this outright. Costs: orphans from a dev crash wait for prod's next restart, and a prod restart stamps the sub-sessions dev is running in a shared workspace stopped.
170
170
 
171
171
  **Shutdown** (`gracefulShutdown` in `index.ts`): for every loaded workspace, `sm.flushAll()` first lands the UI logs not yet persisted (a dirty root plus every live sub-session log — the pending 500ms debounces would die with the process), then `releaseWorkspaceRuntime` unlinks `.halo/runtime.lock` only when it holds *this* process's pid. A crash / SIGKILL leaves the file behind and takes the "pid dead → next boot takes over" path, so the release is an optimisation (a recycled pid can't read as a live holder), never a correctness dependency. The desktop app's `before-quit` (POSIX) holds the quit until the server process has actually exited (3s hard cap), so `killServer`'s SIGKILL fallback for a wedged server still runs instead of being dropped with the Electron process — otherwise a stuck server keeps the port and `server.lock`.
172
172
 
@@ -182,7 +182,7 @@ The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the *
182
182
 
183
183
  **Eager boot** (`index.ts`): rather than waiting for someone to open a workspace, right after constructing `SessionManagerRegistry` (before `bootChannels`) the server calls `listRunningWorkspaces()` and for each workspace with leftover rows whose `.halo` dir still exists: `claimWorkspaceRuntime(ws)` first — if another live server owns it, `continue` (rows stay for a later boot, and no SessionManager is built, since caching a non-owner SM would freeze "not owner" for the whole process lifetime) — otherwise `registry.getOrCreate(ws)`, which fires the same constructor chain (claim → reconcile → retained `sweepActiveGoals` → `sweepInterruptedRuns`). An interrupted root with nobody around is exactly the one that must nudge itself without a human opening the tab first.
184
184
 
185
- `runs.db`/`-wal`/`-shm` join the sandbox `hidden_files` default alongside `evo.db` / `cron.db` (see [storage.md](storage.md)). Known gap carried from the design doc: `<ISO>` in the nudge is sweep time, not the actual interruption time — for a workspace that sat locked by another server before this boot could claim it, that's a real but accepted drift.
185
+ `runs.db`/`-wal`/`-shm` join the built-in sandbox hidden-files list (`DEFAULT_HIDDEN_FILES`, always included; `general.sandbox.hidden_files` only adds to it) alongside `evo.db` / `cron.db` (see [storage.md](storage.md)). Known gap carried from the design doc: `<ISO>` in the nudge is sweep time, not the actual interruption time — for a workspace that sat locked by another server before this boot could claim it, that's a real but accepted drift.
186
186
 
187
187
  ### Message queue and drain
188
188
 
@@ -196,7 +196,7 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
196
196
 
197
197
  **Arrival stamp.** Both paths run the turn through `runAgentTurn`, which prepends `[<ISO-8601 UTC>] ` (e.g. `[2026-09-12T15:17:44.153Z] `) to the first text block of the model-facing input — an image-only input gets a stamp-only text block unshifted. The model has no clock; without this it couldn't tell a reply that came two days later from one that came instantly, or how long a sub-agent report took to land. Stamped **once, before the retry loop**, so every attempt (and the [multimodal 4xx degrade](#multimodal-4xx-degrade) rebuild) reuses the identical input. Agent reports therefore read `[<iso>] (from: session X)\n…` — stamp outermost, no special-casing. Only `agent.messages` changes: the UI `type:'user'` event keeps the raw text (see [Turn matching](#exchange-deletion-soft-ui--hard-raw) for the strip on compare), and the self-compact instruction / `[Conversation Summary]` injection / `tool_result` messages are never stamped.
198
198
 
199
- **Final-text flag.** The agent loop marks each text block `final` when the model call ended for a reason other than `tool_use` (i.e. it is the turn's wrap-up, not the filler emitted before a tool call), and `runAgentTurn`'s `case 'text'` forwards it on the `stream` event as `AgentSessionEvent.final`. Block-send consumers gate on it: the channel responders (wechat / telegram / slack / feishu) append only `final` text, and `halo cli` prints the last root turn's `final` text as its stdout (fallback: that turn's full text). `finalOutput` (used by `tryReportToParent`) is the same accumulation on the server side. Streaming providers additionally yield `text_delta` / `thinking_delta` events during the model call (forwarded as `stream_delta` / `thinking_delta`, UI-only); they never touch `output` / `finalOutput` — those only ever see the whole `text` event, which follows every completed call exactly as before. The one place the loop itself keeps the text deltas is the interrupted-stream landing (see [Partial streamed text lands](#conversation-repair)): a call cancelled mid-stream pushes what it had streamed as a marked assistant turn so the model's history matches what the UI showed.
199
+ **Final-text flag.** The agent loop marks each text block `final` when the model call ended for a reason other than `tool_use` (i.e. it is the turn's wrap-up, not the filler emitted before a tool call), and `runAgentTurn`'s `case 'text'` forwards it on the `stream` event as `AgentSessionEvent.final`. Block-send consumers gate on it: the channel responders (wechat / telegram / slack / feishu / wecom) append only `final` text, and `halo cli` prints the last root turn's `final` text as its stdout (fallback: that turn's full text). `finalOutput` (used by `tryReportToParent`) is the same accumulation on the server side. Streaming providers additionally yield `text_delta` / `thinking_delta` events during the model call (forwarded as `stream_delta` / `thinking_delta`, UI-only); they never touch `output` / `finalOutput` — those only ever see the whole `text` event, which follows every completed call exactly as before. The one place the loop itself keeps the text deltas is the interrupted-stream landing (see [Partial streamed text lands](#conversation-repair)): a call cancelled mid-stream pushes what it had streamed as a marked assistant turn so the model's history matches what the UI showed.
200
200
 
201
201
  **drainQueue** folds the **whole queue** into ONE merged follow-up turn per round, re-checking after each round (a fresh interrupt or a sibling's report can land mid-drain):
202
202
  - The batch is `splice(0)`'d; agent entries keep a `(from: session X)` prefix, user entries fold raw, and all entries' `images` are merged in.
@@ -205,7 +205,7 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
205
205
 
206
206
  **`complete` invariants** (root only):
207
207
  - The **terminal** `complete` is emitted from `runSession`'s `finally`, and `promise = null` is set **before** emitting it — the CLI / web stream-close logic gates on `complete && !hasRunningSessions()`, which reads `promise !== null`; emitting `complete` first would leave the session still "running" at the moment the client decides whether to close.
208
- - **`batchBoundary` complete**: when a merged round finishes **and the queue still has a next round**, `drainQueue` emits `{ type: 'complete', batchBoundary: true }`. This is a per-round flush signal for **block-oriented channels** (wechat / telegram / slack / feishu), which buffer streamed text and only ship a message on `complete` — without it, N drain rounds buffer into one blob that lands only at the terminal `complete` ("8 reports in one lump"). **Stream-terminating consumers (web-channel SSE, ACP) must ignore `batchBoundary` and keep the stream open** — the root is still running and more output follows; only the terminal (unmarked) `complete` closes the stream. See [web.md](web.md) and the per-channel coalescing notes (e.g. [wechat.md](wechat.md)). The field is **not** carried into the WS protocol — admin closes its bubble on `chat:complete` either way.
208
+ - **`batchBoundary` complete**: when a merged round finishes **and the queue still has a next round**, `drainQueue` emits `{ type: 'complete', batchBoundary: true }`. This is a per-round flush signal for **block-oriented channels** (wechat / telegram / slack / feishu / wecom), which buffer streamed text and only ship a message on `complete` — without it, N drain rounds buffer into one blob that lands only at the terminal `complete` ("8 reports in one lump"). **Stream-terminating consumers (web-channel SSE, ACP) must ignore `batchBoundary` and keep the stream open** — the root is still running and more output follows; only the terminal (unmarked) `complete` closes the stream. See [web.md](web.md) and the per-channel coalescing notes (e.g. [wechat.md](wechat.md)). The WS `chat:complete` frame carries the field too — admin closes its bubble either way but rings the finish bell only on the terminal one.
209
209
 
210
210
  **Three-tier interrupt model** — two **soft** paths and one **hard** path, distinguished by *when* the abort fires, never by whether the message is kept (all three preserve the queue):
211
211
  1. **`query_session` while busy — soft interrupt.** `querySession(..., interrupt=false)` enqueues **and** sets `interruptRequested` (same as a user message). This is what makes a sub-agent fold two queued questions into **one merged answer** instead of replying one-by-one: the in-flight turn unwinds after its current tool, then `drainQueue`'s `splice(0)` folds every message that landed alongside it into a single round — matching how root handles two user messages. An **idle** target has no turn to interrupt, so it just runs the message directly.
@@ -229,7 +229,7 @@ A hard interrupt aborts the turn's `AbortSignal`, which `agent-loop.ts` forwards
229
229
 
230
230
  - **`full` access (non-Windows)** spawns via `spawn(command, { shell: true, detached: true })` so the command becomes a **process-group leader** (`pgid === child.pid`). On abort *or* timeout, `process.kill(-pid, 'SIGTERM')` signals the **whole group**, so a compound command's worker dies with the shell.
231
231
  - **Why not `execAsync(command, { signal })`** (the previous implementation): `exec` wraps the command in `/bin/sh -c "<command>"` and, on abort, only SIGTERMs that `sh`. A compound command (`sleep 60 && …`) has already forked the real worker (`sleep`) as a child of `sh` — the signal never reaches it, so it **reparents to init (PPID 1) and runs to completion as an orphan**. The agent turn unwinds correctly (the promise rejects), but the command keeps running, which made `interrupt_session` *look* like it didn't really interrupt. Single non-compound commands didn't expose it (Node optimizes them to a direct `exec` with no `sh` layer).
232
- - **`workspace` / `readonly` access** run under `bwrap` with `--die-with-parent`, a kernel-level guarantee that the sandboxed child dies with its parent — that path was never affected and is unchanged. **Windows** `full` keeps the `execAsync` path (no process groups; the orphan case doesn't arise the same way).
232
+ - **`workspace` / `readonly` access** run under `bwrap` with `--die-with-parent`, a kernel-level guarantee that the sandboxed child dies with its parent — that path was never affected and is unchanged. On macOS they run under Seatbelt (`sandbox-exec`) through the same `spawnGroupExec` group kill. **Windows** `full` keeps the `execAsync` path (no process groups; the orphan case doesn't arise the same way).
233
233
 
234
234
  The `spawnGroupExec` helper preserves `promisify(exec)`'s contract exactly: resolve `{ stdout, stderr }` on exit 0; reject with an `Error` carrying `.message` / `.stdout` / `.stderr` / `.code` otherwise (abort rejects with `name: 'AbortError'`). Covered by `test/sandbox-process-group.test.ts`, which asserts by side effect — a sentinel file the orphaned worker *would* create must never appear after the abort.
235
235
 
@@ -375,7 +375,7 @@ Frontend network issues don't affect the backend:
375
375
  | **4xx multimodal rejection** (`Multimodal data is corrupted` / `Could not process image`) | Replace all image blocks in history with text placeholders, persist, retry — see [Multimodal 4xx degrade](#multimodal-4xx-degrade) below |
376
376
  | Unrecoverable error | Report to user, stop |
377
377
 
378
- **Refusal stop.** `stop_reason: "refusal"` (Anthropic models on Bedrock invoke + generic anthropic) is an HTTP 200 with `stop_details {category, explanation}` — not an error, so it never enters the retry matrix above. `agent-loop.ts:208` handles it explicitly: no assistant message is pushed, any partial `tool_use` is **not** executed, the loop ends; `session-manager.ts:1226` emits a `system` event `⚠️ [<agent>] Model declined to respond (<category>): <explanation>` suggesting `/new`. Previously it fell through the `end_turn` path and looked like a silent stall.
378
+ **Refusal stop.** `stop_reason: "refusal"` (Anthropic models on Bedrock invoke + generic anthropic) is an HTTP 200 with `stop_details {category, explanation}` — not an error, so it never enters the retry matrix above. `agent-loop.ts` handles it explicitly: no assistant message is pushed, any partial `tool_use` is **not** executed, the loop ends; `session-manager.ts` emits a `system` event `⚠️ [<agent>] Model declined to respond (<category>): <explanation>` suggesting `/new`. Previously it fell through the `end_turn` path and looked like a silent stall.
379
379
 
380
380
  **Classification order: structured signal first, message text last.** The interrupt branch is decided by the attempt's own `AbortController` signal (every interrupt path we own goes through `abortReason()` on it), never by the words `cancelled` / `aborted` in the message — an upstream error body can legitimately read `"The operation was aborted due to …"` (Bedrock), and matching that swallowed a real failure as a user interrupt: silent `break`, no retry, no error event, an empty reply. Likewise the account-level branch trusts `httpStatus` when one was recovered: 401/402/403 is terminal, any *other* status is not, whatever the body says — an OpenAI-compatible 503 whose body reads `"authentication service temporarily unavailable"` used to be classified as a dead key and never retried. The keyword list is only consulted when no status could be extracted. Pinned by `turn-retry-idempotent.test.ts` (five classification cases).
381
381
 
@@ -401,7 +401,7 @@ The transient-5xx branch is the one that all providers share, and getting the **
401
401
 
402
402
  **Retry decision** — a failure is treated as a transient server-side error (→ retry with backoff) when **either**:
403
403
 
404
- - `err.name` is `InternalServerException`, `ModelTimeoutException`, or `ServiceUnavailableException`; **or**
404
+ - `err.name` is `InternalServerException`, `ModelTimeoutException`, `ModelStreamErrorException`, or `ServiceUnavailableException`; **or**
405
405
  - the extracted `httpStatus` is one of `500` / `502` / `503` / `504` / `529` (Anthropic "Overloaded") / `408` (request/model timeout).
406
406
 
407
407
  Throttling is checked the same way one branch earlier — structured `ThrottlingException` name or status 429 first, message keywords second; the real Bedrock throttle message ("Too many requests, please wait before trying again.") carries no keyword, so the name/status check is what catches it.
@@ -418,7 +418,7 @@ Three entry points with different quality / safety trade-offs:
418
418
  |---|---|---|---|
419
419
  | 90% soft threshold (mid-turn auto) | `maybeAutoCompact()` via agent-loop's `beforeCallModel` hook — runs before each model call within a turn | **Self-compact** (`selfCompactSession`) — the agent summarizes its own context, then a tail micro-compact pass clears bulk tool output. **Not interruptible, no wall-clock cap; local fallback** if the summary throws, idle-times-out or comes back empty (see below) | The agent already has full context cached (prompt cache hit). No extra model call, no input duplication, no risk of losing tool_result semantics. Firing mid-turn (not just at turn end) stops a single long turn that accumulates many large tool results from blowing the window. |
420
420
  | Overflow mid-loop (`too many input tokens`, `Input is too long`, …) | `runAgentTurn` retry catch → `localCompactMessages` → retry | **Local** — `[role]: <first N chars>` concat, no network call | The model just refused this payload; an LLM round-trip now could stall the recovery path. Local is deterministic and instant; the next end-of-turn can re-summarize via self-compact. |
421
- | User `/session compact` (web, WeChat) | `commands/compact.ts` / `SessionManager.compactSession` | **Self-compact**, cancellable, **no local fallback** | User explicitly requested it; self-compact reuses the cached context so it's fast. A failure (model error / idle timeout) leaves the context unchanged (`Compaction failed — context unchanged`) and is rethrown to the caller; an empty summary emits `Compaction skipped — no summary produced`. |
421
+ | User `/session compact` (web, WeChat) | `channels/shared/commands.ts` (admin WS: `ws/handler.ts`) / `SessionManager.compactSession` | **Self-compact**, cancellable, **no local fallback** | User explicitly requested it; self-compact reuses the cached context so it's fast. A failure (model error / idle timeout) leaves the context unchanged (`Compaction failed — context unchanged`) and is rethrown to the caller; an empty summary emits `Compaction skipped — no summary produced`. |
422
422
 
423
423
  Self-compact **deep-snapshots the keep-region before running the summarize turn** (`messages.slice(cut).map(structuredClone)`), then injects a summarization instruction into the agent's own stream, captures the response, and rebuilds messages as `[summary + snapshot]`. This reuses the provider's prompt cache (no separate model needed) and preserves full semantic context including tool results.
424
424
 
@@ -426,7 +426,7 @@ The pre-run snapshot is load-bearing, not an optimization: `agent.run()` coalesc
426
426
 
427
427
  As a final byte-trimming step, self-compact runs **micro-compact** over that snapshot (`microCompactMessages(cleanRecent, 1)`). Each kept-recent message may still carry a large tool result (e.g. a 50 KB `file_read`), so after summarizing it would otherwise re-cross the threshold immediately. Micro keeps only the newest `tool_result`'s content and clears the rest in place, preserving `tool_use_id` pairing so the next API call stays valid — no extra LLM round-trip. Micro-compact is **not** a standalone compaction path; `selfCompactSession` is its only call site — self-compact is the entry point, micro is its tail cleanup.
428
428
 
429
- All paths share the same split logic — `compactCut(messages)`, exported from `agents/compact.ts` (single source of truth for SessionManager — `selfCompactSession` + the two feasibility gates below — and `localCompactMessages`): keep the last `keepMessages` turns, advance the cut forward past any orphan `tool_result`-first user message (otherwise the next API call gets `unexpected tool_use_id`). The tail loop only ever moves the cut *up*, so `cut === 0` is exactly the `messages.length <= keepMessages` "nothing to compact" case.
429
+ All paths share the same split logic — `compactCut(messages)`, exported from `agents/compact.ts` (single source of truth for SessionManager — `selfCompactSession` + the two feasibility gates below — and `localCompactMessages`): keep the last `keep_messages` turns, advance the cut forward past any orphan `tool_result`-first user message (otherwise the next API call gets `unexpected tool_use_id`). The tail loop only ever moves the cut *up*, so `cut === 0` is exactly the `messages.length <= keep_messages` "nothing to compact" case.
430
430
 
431
431
  **Bounds and Stop.** Self-compact has no wall-clock timeout: a long but still-streaming summary is fine, and a hung call is bounded only by the model-call idle timeout (`timeout.model_request`, re-armed on any data the stream delivers; expiry throws `MODEL_TIMEOUT_ERROR`). An **auto-compact is not interruptible** — `maybeAutoCompact` passes no signal. Stop / Esc (`stopSession`, `interruptSession`, `stopUserSession`, WS `chat:stop` / `chat:interrupt`, `/api/web/stop`) abort only the turn's own controller, which the loop checks once `beforeCallModel` returns, so they take effect after the compact finishes (the stop request itself waits until then). Messages arriving meanwhile queue and run as the next turn once the compact ends; a Stop that folds the queue into history during the compact parks the text in `foldAfterCompact`, and `clearCompacting` lands it on the post-compact history (`foldIntoAgentMessages`). A **manual `/compact`** has no turn, so `cancelCompact` (WS `chat:stop` / `chat:interrupt`, channel `/stop` / `/interrupt` and `POST /api/web/stop` while compacting with no turn running, and `stopUserSession`) cancels it: history rolls back and `Compact cancelled` is emitted.
432
432
 
@@ -438,7 +438,7 @@ All paths share the same split logic — `compactCut(messages)`, exported from `
438
438
 
439
439
  **Local compact output is API-legal on its own.** `localCompactMessages` returns `[summary, ...recent]` where `recent` starts past any `tool_result`-first user message, so every kept `tool_result` has its `tool_use` in the kept tail and `repairConversationMessages` is a no-op on the result (also on a second pass over its own output). Pinned by `local-compact-roundtrip.test.ts` — without it the overflow retry would only have "worked" via the later corrupted-conversation repair retry.
440
440
 
441
- Config (see `config.compact`): `keepMessages` / `maxSummaryInput` / `maxMessageSlice` — editable in Settings → General → compact.
441
+ Config (see `config.compact`): `keep_messages` / `max_summary_input` / `max_message_slice` (settings keys `general.compact.*`) — editable in Settings → General → compact.
442
442
 
443
443
  **Self-compact also archives the UI log.** `selfCompactSession` calls `uiStore.archiveOldMessages(session.id)` for **root and sub-sessions alike** (root-only until 1.5.3-alpha; a long-running sub-agent's UI log then grew without bound — 4.77 MB in testing), right before the compaction notice is emitted so the notice lands in the kept exchange rather than inside the archived segment. Compaction is the one path that already means "history shrinks here", which is why archiving hangs off it rather than off a timer or its own sweep — the size threshold below decides whether the call does anything. See [UI-log archiving](#ui-log-archiving).
444
444
 
@@ -464,7 +464,7 @@ Files: `packages/server/src/sessions/session-archive.ts` (segment IO) · `agents
464
464
 
465
465
  **Trigger: file size.** A compact archives only when the active `<seg>.json` is larger than `ARCHIVE_SIZE_THRESHOLD = 3 MB` (one `statSync`; a missing file counts as 0, so the check is also the "nothing written yet" guard). Below it, `archiveOldMessages` returns 0 and touches nothing, however many exchanges have piled up.
466
466
 
467
- Bytes, not an exchange count, because bytes are the actual problem — file size is what makes a log slow to read, parse and ship — and per-exchange size varies by ~three orders of magnitude (a bare "yes" versus a turn carrying several 50 KB tool results), so a count is a badly distorted proxy: the same "15 exchanges" is 40 KB in one session and 40 MB in another. It is also self-limiting: after an archive the file restarts near-empty, so it must grow another 3 MB of real content before the next segment, which bounds segment count by total bytes written rather than by how often the user compacts. Still no relation to `config.compact.keepMessages` (that counts raw LLM messages — a different granularity with no honest mapping to UI rows), and no setting: one constant, tuned once.
467
+ Bytes, not an exchange count, because bytes are the actual problem — file size is what makes a log slow to read, parse and ship — and per-exchange size varies by ~three orders of magnitude (a bare "yes" versus a turn carrying several 50 KB tool results), so a count is a badly distorted proxy: the same "15 exchanges" is 40 KB in one session and 40 MB in another. It is also self-limiting: after an archive the file restarts near-empty, so it must grow another 3 MB of real content before the next segment, which bounds segment count by total bytes written rather than by how often the user compacts. Still no relation to `config.compact.keep_messages` (that counts raw LLM messages — a different granularity with no honest mapping to UI rows), and no setting: one constant, tuned once.
468
468
 
469
469
  **Split point.** Over the threshold, `archiveSplitIndex(messages, 1)` keeps only the **newest main user exchange** (`role === 'user'` without `taskId` — the same subset the admin renders and `deleteExchange` counts ordinals over) and cuts at its start, so a turn's responses are never split from their user message. A log holding a single exchange yields cut 0 and is left alone: there is nothing to move without splitting the turn the user is looking at, so an oversized *single* exchange is deliberately not archived.
470
470
 
@@ -61,7 +61,7 @@ Files: `packages/server/src/channels/slack/`
61
61
  - `types.ts` — SlackAccount, SlackSocketEnvelope, SlackMessageEvent interfaces
62
62
  - `accounts.ts` — DAL (thin wrapper over `channels/shared/accounts.ts` for Slack-specific mappings)
63
63
  - `handler.ts` — Socket Mode connection + event routing, mention filtering; session resolution + per-thread listener/route bookkeeping come from `channels/shared/inbound.ts` (`InboundBridge` / `deliverInbound` / `dispatchChannelCommand`)
64
- - `event-adapter.ts` — AgentSessionEvent → Slack messages (buffer + flush; split at 35k chars)
64
+ - `event-adapter.ts` — AgentSessionEvent → Slack messages (buffer + flush on `complete`; split at 35k chars)
65
65
  - `api.ts` — HTTP client (auth.test / openSocketModeConnection / chat.postMessage / files.upload / files.download / searchSlackTargets)
66
66
  - `cron-dispatcher.ts` — CronDispatcher registration; requires an explicit Slack chatId per dispatch
67
67
 
@@ -109,13 +109,13 @@ Unlike Telegram (grammy polling) or WeChat (HTTP long-poll), Socket Mode elimina
109
109
 
110
110
  1. SessionManager emits AgentSessionEvent stream chunks
111
111
  2. SlackResponder buffers only chunks flagged `final` (the turn's wrap-up text; pre-tool-call filler is dropped) until `complete` event
112
- 3. On `complete` or when buffer hits 35k chars:
113
- - Split at paragraph boundary if needed via the shared `splitText` (`channels/shared/chunk.ts`; Slack hard-caps messages at ~40k)
112
+ 3. On `complete` (Slack doesn't set `splitMidStream`, so the buffer is never flushed early at the limit):
113
+ - Split at the 35k-char limit, at a paragraph boundary if possible, via the shared `splitText` (`channels/shared/chunk.ts`; Slack hard-caps messages at ~40k)
114
114
  - Extract `MEDIA: <path>` markers (sent as native file uploads)
115
115
  - Convert CommonMark → Slack's mrkdwn format
116
116
  - Post as a single message (or series of messages if split)
117
117
  - This fires on **any** `complete`; the responder ignores its `batchBoundary` flag, so a multi-round queue drain posts each merged turn as its own message rather than one blob (see [session.md](session.md#message-queue-and-drain))
118
- 4. Errors and system notices flush immediately (don't wait for complete)
118
+ 4. Errors (`❌ …`) and system notices (`ℹ️ …`) flush immediately (don't wait for complete)
119
119
  5. Sub-agent events (taskId set) are dropped (visible in web UI only)
120
120
 
121
121
  **Chunk sends are serialized per responder.** One `flushBuffer` can produce
@@ -125,7 +125,9 @@ previous one, so Slack could accept them out of order and a long reply arrived
125
125
  scrambled (audit A-L3). Each responder now keeps a `sendTail` promise chain and
126
126
  appends every chunk to it, so chunk *n+1* only goes out after *n* settles; a
127
127
  rejected link is absorbed (`dispatchChunk` logs its own send failures) so one
128
- bad send can't stall the rest of the reply.
128
+ bad send can't stall the rest of the reply. The buffer + chain live in the
129
+ shared `ChunkedResponder` (`channels/shared/responder.ts`), which slack / feishu
130
+ / wecom / wechat / telegram extend with their own limit and formatter.
129
131
 
130
132
  `close()` therefore returns that drain promise, and `InboundBridge` keeps the
131
133
  session's reply route registered until it settles before deleting it — including
@@ -138,20 +140,20 @@ dropped.
138
140
 
139
141
  ### DM sessions
140
142
 
141
- DM channels have a stable channel_id (`D…`). A message in a DM stays in the same session regardless of turn count. Session key: `slack:${channelId}:dm`.
143
+ DM channels have a stable channel_id (`D…`). A message in a DM stays in the same session regardless of turn count. Session prefix: `slack_${channelId}:dm_`.
142
144
 
143
145
  ### Channel / group / thread sessions
144
146
 
145
- A mention on a top-level message starts a thread and creates a session keyed by that message's `ts`. Replies inside that thread reuse the same `thread_ts`, so they map to the same session and preserve context. Session key: `slack:${channelId}:${rootTs}`.
147
+ A mention on a top-level message starts a thread and creates a session keyed by that message's `ts`. Replies inside that thread reuse the same `thread_ts`, so they map to the same session and preserve context. Session prefix: `slack_${channelId}:${rootTs}_`.
146
148
 
147
149
  ### Session resolution
148
150
 
149
- The `getOrCreateSessionForThread()` helper:
150
- 1. Build session prefix: `slack:${channelId}:${rootTs}:`
151
- 2. Check per-user activeOverrides (set by `/session switch` command in DMs)
152
- 3. Look for an existing session matching the prefix + userId
153
- 4. If none found, create a new session with ID `${prefix}${Date.now().toString(36)}`
154
- 5. Store in activeOverrides for future messages from this user
151
+ `handleInbound()` hands the message to `deliverInbound` (`channels/shared/inbound.ts`), which calls `getOrCreateChannelSession`:
152
+ 1. Build the session prefix with `buildSessionPrefixForThread(channelId, rootTs)` (see above; `rootTs` is `dm` for DMs); the override key is `${userId}@${channelId}:${rootTs}`
153
+ 2. `findActiveSessionId` (`channels/shared/commands.ts`): honour the per-user activeOverrides entry (set by `!session switch` in DMs), else the latest session under the prefix
154
+ 3. If none found, create one with ID `${prefix}${Date.now().toString(36)}` on the workspace's default agent and store it in activeOverrides (`setOverrideOnCreate`)
155
+
156
+ **Reply route after a restart.** Routes and listeners are in-memory, so `startAccount` calls `restoreReplyRoute()`: it reads `lastActiveChatId` (`<channelId>:<rootTs>`) and re-wires the latest existing session under that prefix via `restoreChannelRoute` (no session is created), so a turn that resumes on its own after a restart still reaches Slack. Only that one conversation per account is restored; other threads re-wire on their next inbound message.
155
157
 
156
158
  ## Cron-dispatcher role
157
159
 
@@ -203,7 +205,7 @@ Text, images (base64 attached to agent input for vision), files. All files are d
203
205
 
204
206
  The agent runtime produces `MEDIA: <path>` markers. SlackResponder:
205
207
  - Intercepts the markers and calls `uploadFile()` for each one
206
- - Blocks sandbox violation for non-`full` accounts (files must be under workspace or /tmp — `isMediaPathAllowed(path, workspace, accessLevel)`; `full` accounts may send any readable path). A block throws inside the same try as the upload, so it surfaces as the `handler.upload_failed` text below
208
+ - Blocks sandbox violation for non-`full` accounts (files must be under workspace or /tmp — `isMediaPathAllowed(path, workspace, accessLevel)`; `full` accounts may send any readable path). A block throws inside the same try as the upload (`sendMediaOrReport` in `channels/shared/media.ts`), so it surfaces as the `handler.upload_failed` text below
207
209
  - Falls back to error text if upload fails
208
210
 
209
211
  Outbound media uses Slack's v2 upload flow (getUploadURLExternal → signed PUT → completeUploadExternal).
@@ -218,9 +220,9 @@ Not supported: interactive messages (blocks/buttons/callbacks), slash commands i
218
220
 
219
221
  Slack-specific paths:
220
222
 
221
- - `packages/server/src/channels/slack/handler.ts:252` — `connect()` Socket Mode setup
222
- - `packages/server/src/channels/slack/handler.ts:391` — `handleInbound()` event dispatch
223
- - `packages/server/src/channels/slack/handler.ts:552` — `getOrCreateSessionForThread()` session keying
224
- - `packages/server/src/channels/slack/api.ts:265` — `openSocketModeConnection()` fetch wss:// URL
225
- - `packages/server/src/channels/slack/cron-dispatcher.ts:46` — `dispatch()` cron push logic
226
- - `packages/server/src/channels/slack/event-adapter.ts:90` — `dispatchChunk()` buffer flush + media extraction
223
+ - `packages/server/src/channels/slack/handler.ts` `connect()` (inside `startSlackChannel()`) — Socket Mode setup
224
+ - `packages/server/src/channels/slack/handler.ts` `handleInbound()` — event dispatch
225
+ - `packages/server/src/channels/slack/handler.ts` `buildSessionPrefixForThread()` + `packages/server/src/channels/shared/inbound.ts` `deliverInbound()` / `getOrCreateChannelSession()` — session keying + resolution
226
+ - `packages/server/src/channels/slack/api.ts` `openSocketModeConnection()` — fetch wss:// URL
227
+ - `packages/server/src/channels/slack/cron-dispatcher.ts` `dispatch()` — cron push logic
228
+ - `packages/server/src/channels/shared/responder.ts` — `ChunkedResponder.dispatchChunk()` buffer flush + media extraction (`slack/event-adapter.ts` supplies the 35k limit + `formatForSlack`)
@@ -11,8 +11,8 @@ Defines the persisted-data format for every Halo surface. Format changes must re
11
11
  │ ├── USER.md # User profile (bootstrap-generated)
12
12
  │ ├── prompts/ # User-editable system prompts (externalised)
13
13
  │ │ ├── bootstrap/BOOTSTRAP.md # First-run guidance
14
- │ │ ├── all/ # Every agent (TOOL_GUIDELINES.md, TOOL_SHELL[.windows].md)
15
- │ │ └── root/ # Root agent only — empty by default; user-set
14
+ │ │ ├── all/ # Every agent (TOOL_GUIDELINES.md, TOOL_SHELL[.windows].md, WORKSPACE_CONVENTIONS.md, RUNTIME.md)
15
+ │ │ └── root/ # Root agent only (DELEGATION.md, WORKSPACE_MEMORY.md)
16
16
  │ ├── agents/<id>/
17
17
  │ │ ├── agent.yaml # Agent config
18
18
  │ │ └── AGENT.md # Agent personality
@@ -26,7 +26,8 @@ Defines the persisted-data format for every Halo surface. Format changes must re
26
26
  │ ├── evo.db # Cross-workspace evolution queue (evolution_runs + evolution_applies)
27
27
  │ ├── cron.db # Cross-workspace cron jobs + run history (cron_jobs + cron_runs)
28
28
  │ ├── runs.db # Run ledger: (workspace, session_id) rows for server-driven runs in flight; steady state empty (running_sessions)
29
- │ ├── logs/ # Runtime logs
29
+ │ ├── extensions/<id>/ # Installed Canvas preview extensions (halo-extension.json + bundle)
30
+ │ ├── logs/ # Runtime logs (server.log)
30
31
  │ │ ├── evo/ # Per-evo-run wrapper logs
31
32
  │ │ └── cron/ # Per-cron-run cli stdout/stderr (30-day retention)
32
33
  │ ├── server.lock # Single-instance pid lock
@@ -62,7 +63,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
62
63
  ├── tmp/ # Agent scratch files (logs, downloads, intermediate artifacts) — convention from TOOL_GUIDELINES
63
64
  ├── assets/<channel>/inbound/<accountId>/<date>/ # Inbound media per channel (image/voice/video/file)
64
65
  ├── runtime.lock # Workspace runtime ownership marker (pid) — see below
65
- ├── halo.db # Per-workspace sqlite (sessions metadata, command registry, disabled-items)
66
+ ├── halo.db # Per-workspace sqlite (sessions metadata, disabled-items)
66
67
  └── docs/ # Project docs (requirements/design/dev/test/plans)
67
68
  ```
68
69
 
@@ -96,6 +97,8 @@ Protocol (single-machine scope; NFS / cross-host explicitly out):
96
97
 
97
98
  Bias is deliberate: pid reuse can read a stale pid as "alive," which only costs one missed cleanup round; a false-dead could stop another process's live sessions. Full race analysis in the file's header comment.
98
99
 
100
+ **`HALO_BADGE=DEV` servers never claim.** dev and prod serve the same workspaces, so prod (any other badge, or none) owns the lock outright: a DEV server (`config.server.ownsWorkspaceRuntimes === false`) builds its registry with `reconcileOrphansOnBoot: false`, skips the run-ledger eager sweep, and has channel route restore build the SessionManager directly. It never creates, rewrites or takes over `runtime.lock`, and runs no boot cleanup. Two costs: orphans left by a dev crash stay until prod's next restart cleans them, and a prod restart stamps the sub-sessions dev is running in a shared workspace stopped. That is what prod ownership means.
101
+
99
102
  ## Session file format (v1)
100
103
 
101
104
  Path: `.halo/sessions/{agentId}/{sessionId}.json`
@@ -148,11 +151,11 @@ type MessageType =
148
151
  interface SessionMessage {
149
152
  // Required
150
153
  id: string // "m_{timestamp36}_{counter36}"
151
- type: MessageType
154
+ type?: MessageType // optional for backward compat (inferMessageType)
152
155
  role: 'user' | 'assistant' | 'system'
153
156
  content: string
154
157
  timestamp: number // Unix ms
155
- agentName: string
158
+ agentName?: string
156
159
 
157
160
  // Optional scope
158
161
  taskId?: string // Sub-agent task ID
@@ -181,6 +184,9 @@ interface SessionMessage {
181
184
 
182
185
  // Transient (not persisted)
183
186
  streaming?: boolean
187
+
188
+ // Soft delete (deleteExchange) — greyed out in the UI, removed from raw context
189
+ deleted?: boolean
184
190
  }
185
191
  ```
186
192
 
@@ -287,12 +293,15 @@ interface AgentYamlConfig {
287
293
  maxTokens?: number
288
294
  promptCaching?: boolean | string
289
295
  thinking?: { enabled?: boolean; budget?: string; effort?: string }
296
+ verbosity?: string
290
297
  }
291
298
  system_prompt?: string
292
299
  tools?: string[]
293
300
  skills?: string[]
294
301
  context?: { maxTokens?: number; compressAt?: number }
295
302
  priority?: number
303
+ internal?: boolean // platform tooling — off the roster, no workspace context
304
+ team?: string[] // delegation whitelist; non-empty = can delegate
296
305
  }
297
306
  ```
298
307
 
@@ -335,8 +344,8 @@ general: # built-in declarer (the server itself
335
344
  max_summary_input: 15000
336
345
  max_message_slice: 800
337
346
  sandbox:
338
- hidden_dirs: "~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh"
339
- hidden_files: "~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/evo.db,~/.halo/global/evo.db-wal,~/.halo/global/evo.db-shm,~/.halo/global/cron.db,~/.halo/global/cron.db-wal,~/.halo/global/cron.db-shm,~/.halo/global/runs.db,~/.halo/global/runs.db-wal,~/.halo/global/runs.db-shm"
347
+ hidden_dirs: "~/.kube" # extras, appended to the built-in list
348
+ hidden_files: "~/.pgpass" # extras, appended to the built-in list
340
349
  logging:
341
350
  level: warn
342
351
 
@@ -378,21 +387,20 @@ A value of the form `<<ENV_NAME>>` is replaced at read time with `process.env.EN
378
387
 
379
388
  | Path | Default | Notes |
380
389
  |---|---|---|
381
- | `general.session.max_queue_size` | 256 | Max queued messages per session |
382
- | `general.session.max_nesting_depth` | 16 | Sub-session nesting cap |
383
- | `general.compact.keep_messages` | 5 | Recent messages kept intact during compaction |
384
- | `general.compact.max_summary_input` | 15000 | Local truncation fallback total char cap |
385
- | `general.compact.max_message_slice` | 800 | Local truncation per-message char cap |
390
+ | `general.session.max_queue_size` | 256 | Max queued messages per session. Restart required |
391
+ | `general.session.max_nesting_depth` | 16 | Sub-session nesting cap. Restart required |
392
+ | `general.compact.keep_messages` | 5 | Recent messages kept intact during compaction. Restart required |
393
+ | `general.compact.max_summary_input` | 15000 | Local truncation fallback total char cap. Restart required |
394
+ | `general.compact.max_message_slice` | 800 | Local truncation per-message char cap. Restart required |
386
395
  | `general.limits.shell_output_bytes` | 5242880 | Max bytes captured from one `shell_exec` (stdout+stderr); excess truncated with a `[truncated]` marker |
387
396
  | `general.limits.web_fetch_bytes` | 51200 | Max bytes downloaded by one `web_fetch` |
388
397
  | `general.limits.grep_default_matches` | 50 | Default `grep` match cap when no explicit `max` is passed |
389
398
  | `general.limits.tool_result_render_chars` | 8000 | Per-tool-result cap on the content **fed to the LLM** (truncated with a re-run marker to protect the context window / prompt cache). The UI gets a much larger slice — see `tool_result_ui_chars` and `agent-loop.ts` (`resultContent` = LLM cap, `resultTextFull` = UI cap) |
390
399
  | `general.limits.tool_result_ui_chars` | 65536 | Per-tool-result cap on the content **stored for UI display** (admin/web chat panel). Far larger than the LLM cap so a normal command's full output stays visible, but bounded so a multi-MB `cat` can't bloat the session file / WS payload / browser render; excess truncated with a marker pointing at `file_read` |
391
- | `general.limits.ws_event_buffer` | 5000 | Events buffered per detached WS session before oldest are dropped on reattach |
392
400
  | `general.limits.terminal_scrollback_bytes` | 50000 | Off-screen scrollback bytes retained per detached persistent terminal |
393
- | `general.sandbox.hidden_dirs` | `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs` | Hidden from workspace/readonly sessions — bwrap tmpfs overlay (Linux) / Seatbelt deny (macOS) |
394
- | `general.sandbox.hidden_files` | `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/evo.db,~/.halo/global/evo.db-wal,~/.halo/global/evo.db-shm,~/.halo/global/cron.db,~/.halo/global/cron.db-wal,~/.halo/global/cron.db-shm,~/.halo/global/runs.db,~/.halo/global/runs.db-wal,~/.halo/global/runs.db-shm` | Hidden from workspace/readonly sessions — bwrap bind of the empty `~/.halo/.sandbox-empty`, reads as empty (Linux) / Seatbelt deny (macOS) |
395
- | `general.logging.level` | warn | `debug` / `info` / `warn` / `error` |
401
+ | `general.sandbox.hidden_dirs` | `''` | Extra dirs hidden from workspace/readonly sessions — bwrap tmpfs overlay (Linux) / Seatbelt deny (macOS). **Appended** to the built-in `DEFAULT_HIDDEN_DIRS` (`tools/sandbox.ts`: `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs`), which are always included. Full sessions, the admin file explorer and the terminal are not affected |
402
+ | `general.sandbox.hidden_files` | `''` | Extra files hidden from workspace/readonly sessions — bwrap bind of the empty `~/.halo/.sandbox-empty`, reads as empty (Linux) / Seatbelt deny (macOS). **Appended** to the built-in `DEFAULT_HIDDEN_FILES` (`~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + `-wal` / `-shm`), which are always included. Full sessions, the admin file explorer and the terminal are not affected |
403
+ | `general.logging.level` | warn | `debug` / `info` / `warn` / `error`. Restart required |
396
404
  | `general.observability.endpoint` | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`); empty = off. Restart required. See [observability.md](observability.md) |
397
405
  | `general.observability.service_name` | `halo` | OTel resource `service.name`. Restart required |
398
406
  | `general.observability.headers` | `''` | **Secret.** Extra OTLP request headers, comma-separated `k=v`. Restart required |
@@ -402,7 +410,7 @@ Schema source: [packages/server/src/settings-schema.ts](../../../packages/server
402
410
 
403
411
  ### Caching
404
412
 
405
- `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**.
413
+ `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.
406
414
 
407
415
  ### Orphans
408
416
 
@@ -536,6 +544,8 @@ interface SkillMeta {
536
544
  name: string // from frontmatter
537
545
  description: string // from frontmatter
538
546
  path: string // absolute path to SKILL.md
547
+ command?: string // from frontmatter
548
+ requiresAccess?: 'full' | 'workspace' | 'readonly' // from frontmatter
539
549
  }
540
550
  ```
541
551
 
@@ -552,16 +562,6 @@ The databases hold session metadata indexes and workspace-scoped preferences (e.
552
562
 
553
563
  ### Tables
554
564
 
555
- **`sessions`** — frontend session index (created on first chat)
556
-
557
- | Column | Type | Notes |
558
- |---|---|---|
559
- | id | TEXT PK | Session ID |
560
- | title | TEXT NOT NULL DEFAULT '' | Truncated first user message |
561
- | messages | TEXT | Legacy field, unused (content now in JSON) |
562
- | message_count | INTEGER | Message count |
563
- | created_at / updated_at | INTEGER | Unix ms |
564
-
565
565
  **`agent_sessions`** — agent session index (root + sub-sessions)
566
566
 
567
567
  | Column | Type | Notes |
@@ -602,7 +602,7 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
602
602
  | Column | Type | Notes |
603
603
  |---|---|---|
604
604
  | account_id | TEXT PK | Channel-specific ID (e.g. `halo_agent_bot`, `abc-im-bot`, `e718bb7b`) |
605
- | channel_type | TEXT NOT NULL | `'telegram'`, `'web'`, `'wechat'`, `'slack'`, or `'feishu'` |
605
+ | channel_type | TEXT NOT NULL | `'telegram'`, `'web'`, `'wechat'`, `'slack'`, `'feishu'`, or `'wecom'` |
606
606
  | workspace_path | TEXT NOT NULL | Absolute path of the bound workspace |
607
607
  | label | TEXT | User-chosen name |
608
608
  | enabled | INTEGER | 1 = active, 0 = disabled |
@@ -620,6 +620,7 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
620
620
  | wechat | `botToken`, `baseUrl`, `userId`, `syncBuf` |
621
621
  | slack | `botToken`, `appToken`, `botUserId`, `teamId` |
622
622
  | feishu | `appId`, `appSecret`, `verificationToken`, `encryptKey`, `botOpenId` |
623
+ | wecom | `botId`, `secret` |
623
624
 
624
625
  ### Schema change rules
625
626