@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
@@ -17,11 +17,11 @@ All channels are peers — each one is a subscriber + caller against SessionMana
17
17
 
18
18
  ## Shared inbound skeleton
19
19
 
20
- All four IM channels (telegram / wechat / slack / feishu) route inbound messages through `channels/shared/inbound.ts` rather than each keeping its own copy of the same ~200-line tail. The copies had drifted independently, which is how two audited bugs happened (audit A, 2026-08-06): the wechat responder captured its recipient at listener-registration time (A-M2), and all four command-dispatch sites ignored `CommandResult.startedTurn`, so a skill command on a fresh session kicked the agent with no listener attached and the reply vanished (A-M5).
20
+ All five IM channels (telegram / wechat / slack / feishu / wecom) route inbound messages through `channels/shared/inbound.ts` rather than each keeping its own copy of the same ~200-line tail. The copies had drifted independently, which is how two audited bugs happened (audit A, 2026-08-06): the wechat responder captured its recipient at listener-registration time (A-M2), and all the command-dispatch sites ignored `CommandResult.startedTurn`, so a skill command on a fresh session kicked the agent with no listener attached and the reply vanished (A-M5).
21
21
 
22
22
  Three pieces:
23
23
 
24
- - **`InboundBridge<Route>`** — per-account bookkeeping for session listeners + their reply routes (`setRoute` / `getRoute` / `ensureListener` / `dropListener` / `closeAll`). The **reply route** — where to send, plus any per-message credential like wechat's context token — is a per-session value refreshed on **every** inbound message, and responders read it lazily at send time via `bridge.getRoute(sessionId)`. A listener registered once therefore can't lock onto a stale destination. Route entries are dropped together with their listener, so the map can't outgrow the listener set (the old per-channel context-token map never got cleaned up) — but **only once the responder has drained**: `close()` may return a promise (slack / feishu / wechat serialize their per-chunk sends and hand one back; telegram still fires each chunk and returns `void`), and the bridge defers the route deletion until it settles. Dropping the route synchronously stranded the tail of a split reply with nowhere to send (audit A-L3). `ensureListener` is idempotent per session; the account runner owns the bridge so `stopAccount` tears everything down with `closeAll()`, which walks the same per-listener teardown — deliberately no blanket `routes.clear()`, since that would delete the routes in-flight sends are about to read.
24
+ - **`InboundBridge<Route>`** — per-account bookkeeping for session listeners + their reply routes (`setRoute` / `getRoute` / `ensureListener` / `dropListener` / `closeAll`). The **reply route** — where to send, plus any per-message credential like wechat's context token — is a per-session value refreshed on **every** inbound message, and responders read it lazily at send time via `bridge.getRoute(sessionId)`. A listener registered once therefore can't lock onto a stale destination. Route entries are dropped together with their listener, so the map can't outgrow the listener set (the old per-channel context-token map never got cleaned up) — but **only once the responder has drained**: `close()` returns a promise (every responder extends the shared `ChunkedResponder`, which serializes its per-chunk sends and hands the chain's tail back), and the bridge defers the route deletion until it settles. Dropping the route synchronously stranded the tail of a split reply with nowhere to send (audit A-L3). `ensureListener` is idempotent per session; the account runner owns the bridge so `stopAccount` tears everything down with `closeAll()`, which walks the same per-listener teardown — deliberately no blanket `routes.clear()`, since that would delete the routes in-flight sends are about to read.
25
25
  - **`deliverInbound`** — the tail itself: `rememberLastActiveChat` → access-level projection → resolve-or-create the channel session → busy/compacting hint (hint only; delivery continues regardless) → `setRoute` → `ensureListener` → `appendUserMessage(uiText)` + `sendUserMessage("[channel: X | user: Y | thread: Z]\n" + agentText)`. Returns the session id.
26
26
  - **`dispatchChannelCommand`** — wraps the shared `dispatchCommand` and, when the result says `startedTurn`, wires route + listener exactly like the plain-message path, so no channel can forget it again. A call site that can't build a route logs instead of attaching a responder that could never send.
27
27
 
@@ -37,6 +37,7 @@ One bot (= one BotFather token) is bound to one workspace. A workspace can bind
37
37
 
38
38
  - **One Telegram user → many sessions (one active at a time)**
39
39
  - Session ID format: `tg_<userId>_<createdAtBase36>` (e.g. `tg_123456789_m1abc`)
40
+ - Keyed by the **sender** (`from.id`), not the chat: a user's DM and every group they talk to the bot in share the same sessions (see [Group chats](#group-chats))
40
41
  - Sessions live under the bot's bound workspace and use the highest-priority agent (falls back to `default` only when none exists)
41
42
  - The web side sees these sessions in the session list (labelled `Telegram: <userId>`)
42
43
  - Access level inherited from the bot account
@@ -61,7 +62,7 @@ Telegram-specific config JSON fields: `botToken`, `botUsername`, `allowedUsers`,
61
62
 
62
63
  Halo can send to a Telegram chat without that user having messaged the bot first — Telegram's Bot API allows `sendMessage(chatId, text)` for any known chat id. Halo only requires that the chat id come from a *trusted source*.
63
64
 
64
- The telegram cron-dispatcher (`channels/telegram/cron-dispatcher.ts`) registers itself with `cron/dispatcher.ts`'s registry at boot and **requires an explicit `chatId`** (numeric — Telegram private-chat ids equal user ids):
65
+ The telegram cron-dispatcher (`channels/telegram/cron-dispatcher.ts`) registers itself with `cron/dispatcher.ts`'s registry at boot and **requires an explicit `chatId`** (numeric — Telegram private-chat ids equal user ids, group ids are negative):
65
66
 
66
67
  - Cron jobs created from inside a telegram chat via the `cron` skill auto-pin the current chat id (target `telegram:<account>:<chatId>`).
67
68
  - Admin-UI cron jobs that don't specify a target run silently — the result shows in the cron log, nothing is pushed.
@@ -69,6 +70,8 @@ The telegram cron-dispatcher (`channels/telegram/cron-dispatcher.ts`) registers
69
70
 
70
71
  Earlier drafts considered "fan-out to every id in `allowedUsers`" or "fall back to `lastActiveChatId`", but in practice both pushed the cron output to strangers' chats. The cron creator's intent is "reach me, the person who set this up" — so the only target is what the caller passes explicitly.
71
72
 
73
+ The report is chunked like a chat reply — `splitText(text, TELEGRAM_TEXT_LIMIT)` (4000 chars, exported from `event-adapter.ts`; the Bot API rejects a message over 4096) — and the chunks are sent sequentially so a long report arrives in order. Before this the whole report went out in one `sendMessage` and anything over the cap failed outright. A failed chunk stops the send and is recorded as `chunk i/n: <error>` in the target's result row (chunks before it were delivered and stay); full success is one `ok` row regardless of chunk count. Same contract as the wechat dispatcher (see [cron.md](cron.md#dispatch-model)).
74
+
72
75
  ## Modules
73
76
 
74
77
  Files: `packages/server/src/channels/telegram/`
@@ -102,6 +105,20 @@ Routes: `packages/server/src/routes/telegram.ts`
102
105
 
103
106
  Photos / documents / voice / video notes trigger the same flow with the saved-file note in the text.
104
107
 
108
+ Builtin commands (`bot.command`) run the same `allowedUsers` check first and reply `handler.not_allowed` on a miss — before, only plain messages were gated, so any member of a group the bot sat in could run `/session` / `/workspace switch` at the account's access level. A command with no sender (`channel_post` in a channel the bot administers) is ignored.
109
+
110
+ ### Group chats
111
+
112
+ Supported, with Telegram's own visibility rules deciding what arrives:
113
+
114
+ - **What the bot receives** — Telegram, not halo, filters. Bots default to *privacy mode* in groups: only `/cmd@thisbot`, replies to the bot's messages, and a few edge cases are delivered; disable privacy mode in BotFather (`/setprivacy`) or make the bot a group admin to receive every message. Halo has no @-mention gate of its own.
115
+ - **Session isolation: per user.** The session prefix is `tg_<from.id>_` and the `activeOverrides` key is the user id, so each member gets their own session, and it is the *same* session they use in DM.
116
+ - **Reply destination: the chat the message came from.** The route is `{ chatId: ctx.chat.id }`, so a group message is answered in the group. The route is refreshed per message — a reply follows the user's latest chat (DM or group).
117
+ - **Permission check: the sender id.** `allowedUsers` matches `from.id` / `from.username`, never the chat id, for messages and commands alike.
118
+ - **Commands** — `bot.command` matches `/cmd@thisbot` for builtins; the skill-command path strips an `@<this bot>` suffix the same way (a suffix naming another bot is left alone and falls through to chat).
119
+ - **Restart restore** — `lastActiveChatId` stores the chat id; a group id is negative and doesn't name the member whose session it was, so `restoreReplyRoute` only restores private chats (`^\d+$`).
120
+ - **Cron** — the dispatcher accepts any numeric chat id, negative group ids included; a cron created from inside a group auto-pins the group.
121
+
105
122
  ## Slash commands (native Telegram /commands)
106
123
 
107
124
  | Command | Purpose |
@@ -117,10 +134,11 @@ Photos / documents / voice / video notes trigger the same flow with the saved-fi
117
134
 
118
135
  ## Event coalescing (TelegramResponder)
119
136
 
120
- Same strategy as WeChat:
137
+ Same strategy as WeChat — `TelegramResponder` is the shared `ChunkedResponder` (`channels/shared/responder.ts`) with `{ limit: 4000, logTag: 'Telegram', splitMidStream: true }`:
121
138
  - Buffer only `stream` events flagged `final` (the turn's wrap-up text; the filler the model emits before a tool call is dropped) until `complete`, then flush as one message
122
139
  - Flush happens on **any** `complete` — the responder doesn't read its `batchBoundary` flag, so a multi-round queue drain ships each merged turn as its own message instead of one blob (see [session.md](session.md#message-queue-and-drain))
123
- - If buffer exceeds 4000 chars (Telegram limit), split at paragraph boundary via the shared `splitText` (`channels/shared/chunk.ts`, used by all four responders)
140
+ - If buffer exceeds 4000 chars (Telegram limit), split at paragraph boundary via the shared `splitText` (`channels/shared/chunk.ts`), mid-stream and again on flush
141
+ - Every chunk goes through one serialized send chain, so a long reply arrives in order; `close()` returns the drain promise. Before this the telegram responder fired each chunk with `void dispatchChunk`, so the `sendMessage` calls raced and a >4000-char reply could land shuffled (the A-L3 fix had covered slack / feishu / wecom / wechat only)
124
142
  - `MEDIA:<path>` markers are intercepted and sent as native Telegram media
125
143
  - Errors flush immediately
126
144
  - Sub-agent events are dropped (visible in web UI only)
@@ -138,7 +156,7 @@ Outbound: the `send-file` skill produces `MEDIA:<path>` markers. The responder s
138
156
  - `.ogg/.oga` → `sendVoice`
139
157
  - anything else → `sendDocument`
140
158
 
141
- Path whitelist: a `full` account may send any readable path; other access levels are limited to the workspace or the OS temp dir (`isMediaPathAllowed(path, workspace, accessLevel)`). A blocked path — or any failed send — is reported to the chat as `t('handler.upload_failed')` (`⚠️ 文件上传失败:name — error`), same as slack / feishu / wecom; before this it was only a server log line.
159
+ Path whitelist: a `full` account may send any readable path; other access levels are limited to the workspace or the OS temp dir (`isMediaPathAllowed(path, workspace, accessLevel)`). A blocked path — or any failed send — is reported to the chat as `t('handler.upload_failed')` (`sendMediaOrReport` in `channels/shared/media.ts`) (`⚠️ 文件上传失败:name — error`), same as slack / feishu / wecom; before this it was only a server log line.
142
160
 
143
161
  ## Configuration
144
162
 
@@ -170,6 +188,6 @@ curl -X POST http://localhost:9527/api/telegram/accounts \
170
188
 
171
189
  ## Scope and out-of-scope
172
190
 
173
- Supported: private chat text, photos, documents; slash commands; per-session access level; user whitelist; media sending.
191
+ Supported: private and group chat text, photos, documents; slash commands; per-session access level; user whitelist; media sending.
174
192
 
175
- Not supported: group chats, inline queries, callback buttons, webhook mode (polling only).
193
+ Not supported: inline queries, callback buttons, webhook mode (polling only).
@@ -91,7 +91,9 @@ data: {"type":"complete"}
91
91
  data: {"type":"error","error":"..."}
92
92
  ```
93
93
 
94
- > **Batch-boundary `complete` (must be absorbed, never closes the stream)**: when a root session drains a queue of multiple messages, the server runs N merged turns and emits an internal `complete` with `batchBoundary: true` between rounds (see [session.md](session.md#message-queue-and-drain)). The SSE generators in `channels/web/handler.ts` flush the just-finished round's text on a `batchBoundary` complete but **do not** send a `complete` SSE frame and **do not** set `done` — the response stays open for the next round; only the **terminal** (unmarked) `complete` closes the HTTP stream. Without this guard a producer→sub-agent fan-out would truncate the web client after the first round. The `batchBoundary` flag is therefore an internal server-side event marker only — it is never serialized into the SSE payload a client sees, so a custom frontend just consumes one ordinary `complete` at the end. (ACP rides on this channel, so it inherits the same safe behavior.)
94
+ > **Batch-boundary `complete` (must be absorbed, never closes the stream)**: when a root session drains a queue of multiple messages, the server runs N merged turns and emits an internal `complete` with `batchBoundary: true` between rounds (see [session.md](session.md#message-queue-and-drain)). The SSE generators in `channels/web/handler.ts` (`listenSession().events()` plus the `createMediaBuffer()` event processor) flush the just-finished round's text on a `batchBoundary` complete but **do not** send a `complete` SSE frame and **do not** set `done` — the response stays open for the next round; only the **terminal** (unmarked) `complete` closes the HTTP stream. Without this guard a producer→sub-agent fan-out would truncate the web client after the first round. The `batchBoundary` flag is therefore an internal server-side event marker only — it is never serialized into the SSE payload a client sees, so a custom frontend just consumes one ordinary `complete` at the end. (ACP rides on this channel, so it inherits the same safe behavior.)
95
+
96
+ > **Listener lifetime**: `handleMessage` registers its session listener (`listenSession`) *before* the media save and `sendUserMessage` — it must be listening when the turn starts — and wraps everything from there to the end of `events()` in a `try/finally` that closes it. So a throw in between (media save failed, `sendUserMessage` threw), a `queued` result, or the end of the stream all drop the listener; before, only `events()`'s own finally and the `queued` branch did, and an early throw leaked it for the process lifetime. The unsubscribe is idempotent, so closing twice on the normal path is harmless. The route passes the request's abort signal (`c.req.raw.signal`) through to `events(signal)` — for `/web/chat` and a skill command's follow-on stream, as `/web/subscribe` already did — so a client that disconnects mid-turn ends the stream and drops the listener right away instead of at the turn's terminal `complete`. The turn itself keeps running; disconnecting is not `/web/stop`.
95
97
 
96
98
  #### Per-request overrides (`workspace`, `sessionId`, `agentId`)
97
99
 
@@ -104,6 +104,8 @@ loop {
104
104
  3. If the text begins with `/`, dispatch a slash command (see below) — if it returns `handled`, stop here
105
105
  4. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)), which caches `lastActiveChatId`, resolves-or-creates the session, sends a busy/compacting hint when needed, refreshes the reply route (`wxRoute(fromUserId, context_token)`), attaches the `WechatResponder` listener once, and delivers: clean text to the UI log, `[channel: wechat | user: <id>]\n<text>` to the agent (so it knows how to address the media-send skill)
106
106
 
107
+ **After a restart**, `startAccount` calls `restoreReplyRoutes()` before the loop starts: every user with a stored `contextTokens` entry gets a reply route + listener on their latest existing session (`restoreChannelRoute`, no session created), so a turn that resumes on its own still reaches WeChat.
108
+
107
109
  ## Slash commands
108
110
 
109
111
  Implemented in `handler.ts`'s `handleSlashCommand()`:
@@ -124,12 +126,12 @@ Unknown `/` input falls through to normal message handling.
124
126
  WeChat `sendMessage` is block-send, while LLMs stream. The current strategy is "buffer the turn's reply, flush as one message":
125
127
  - Only `stream` events flagged `final` (the wrap-up text of a turn — see [session.md](session.md#message-queue-and-drain)) accumulate in a single buffer; the filler the model emits before a tool call is dropped (it stays in the web UI)
126
128
  - `complete` → flush the whole buffer as one (or more) messages. The responder flushes on **any** `complete` and does not read its `batchBoundary` flag — so when the root drains a multi-message queue across several merged turns (see [session.md](session.md#message-queue-and-drain)), each round's batch-boundary `complete` ships that round as its own message instead of buffering every round into one blob that lands only at the terminal `complete` (the "8 reports in one lump" bug)
127
- - Hard cap `WECHAT_TEXT_LIMIT = 3500` **chars** (JS string length, not bytes — the ilink gateway rejects a `sendmessage` body over 16 KB with `ret=-2 "prepare failed"`; 3500 chars is ≤ ~10.5 KB of CJK plus the JSON envelope). When the buffer exceeds it mid-stream, the shared `splitText` (`channels/shared/chunk.ts`) cuts at the nearest paragraph break (or hard-cuts) and dispatches; the under-limit remainder keeps accumulating. Exported so the cron dispatcher obeys the same limit (see [Proactive sending](#proactive-sending-cron))
128
- - Every chunk goes through one serialized send chain per responder (`sendTail`, same as Slack / Feishu) so a long reply arrives in order; `close()` returns the drain promise and `InboundBridge` keeps the reply route alive until it settles
129
- - `error` → immediate flush + send a `[错误] …` message; `system` → immediate flush + send a `[系统] …` message
129
+ - Hard cap `WECHAT_TEXT_LIMIT = 3500` **chars** (JS string length, not bytes — the ilink gateway rejects a `sendmessage` body over 16 KB with `ret=-2 "prepare failed"`; 3500 chars is ≤ ~10.5 KB of CJK plus the JSON envelope). WeChat is one of the two channels that set `splitMidStream` (with Telegram): when the buffer exceeds the limit mid-stream, the shared `splitText` (`channels/shared/chunk.ts`) cuts at the nearest paragraph break (or hard-cuts) and dispatches; the under-limit remainder keeps accumulating. Exported so the cron dispatcher obeys the same limit (see [Proactive sending](#proactive-sending-cron))
130
+ - Every chunk goes through one serialized send chain per responder (`sendTail` in the shared `ChunkedResponder`, `channels/shared/responder.ts`, same as Slack / Feishu) so a long reply arrives in order; `close()` returns the drain promise and `InboundBridge` keeps the reply route alive until it settles
131
+ - `error` → immediate flush + send a `❌ …` message; `system` → immediate flush + send a `ℹ️ …` message
130
132
  - Tool calls / tool results / thinking are dropped (detail lives in the web UI)
131
- - Media: the agent emits `MEDIA: <path>` markers, which the responder extracts and turns into actual media uploads. A `full` account may send any readable path (its shell / file tools are unrestricted anyway); other access levels are limited to the account's workspace or the OS temp dir (`/tmp`) — `isMediaPathAllowed(path, workspace, accessLevel)`. A blocked path (e.g. another workspace's files) throws inside `sendMedia`, so it rides the responder's `onSendError` path and lands in the session log as a `⚠️ WeChat delivery failed: sendMedia … media path not allowed` notification instead of being dropped silently
132
- - Send failures are surfaced, never retried: when `sendText` / `sendMedia` rejects (typically the gateway's `ret=-2`), `dispatchChunk` logs it and calls the optional `onSendError(message)` dep; the handler wires that to `sm.appendNotification(sessionId, '⚠️ WeChat delivery failed: …')`, so the session log shows the turn ran but its reply never reached WeChat. `appendNotification` only appends a `notification` row and persists — it emits no event, which matters: a `system` event would loop back into this responder's `case 'system'` and try to send to WeChat again
133
+ - Media: the agent emits `MEDIA: <path>` markers, which the responder extracts and turns into actual media uploads. A `full` account may send any readable path (its shell / file tools are unrestricted anyway); other access levels are limited to the account's workspace or the OS temp dir (`/tmp`) — `isMediaPathAllowed(path, workspace, accessLevel)`. A blocked path (e.g. another workspace's files) throws inside `sendMedia` (`assertMediaPathAllowed`), so it rides the responder's `onSendError` path and lands in the session log as a `⚠️ WeChat delivery failed: sendMedia … media path not allowed` notification instead of being dropped silently
134
+ - Send failures are surfaced, never retried: when `sendText` / `sendMedia` rejects (typically the gateway's `ret=-2`), `dispatchChunk` logs it and calls the optional `onSendError(message)` dep (via the `onSendFailed` override); the handler wires that to `sm.appendNotification(sessionId, '⚠️ WeChat delivery failed: …')`, so the session log shows the turn ran but its reply never reached WeChat. `appendNotification` only appends a `notification` row and persists — it emits no event, which matters: a `system` event would loop back into this responder's `case 'system'` and try to send to WeChat again
133
135
 
134
136
  **The responder never captures its recipient.** Both `sendText` and `sendMedia` read `bridge.getRoute(sessionId)` at send time, so a `{fromUserId, contextToken}` refreshed by a later inbound message takes effect immediately. Closing over `fromUserId` at listener-registration time was the A-M2 bug: after a full-access `/session switch` moved a session to another user, the listener kept replying to the *first* one. `contextToken` (WeChat's passive-reply-window credential) is carried over between messages **only while the user is the same** — a new user's route starts without the old token rather than replaying it against the new recipient. `/session switch` additionally calls `bridge.dropListener(oldSid)` before wiring the target so the abandoned session doesn't keep a live responder.
135
137
 
@@ -141,7 +143,7 @@ Plain text + images (base64 attached to the agent message), voice, video, files.
141
143
 
142
144
  Supported: text, images, voice, video, files; slash commands listed above; per-session access level.
143
145
 
144
- Not supported: group chats, typing indicators.
146
+ Not supported: group chats — a platform limit, not a halo filter: the iLink bot is a one-to-one contact and `getupdates` never carries group messages, so the handler has no group path at all; typing indicators.
145
147
 
146
148
  ## References
147
149
 
@@ -33,7 +33,7 @@ One 智能机器人 (= one botId + secret pair) is bound to one workspace. A wor
33
33
 
34
34
  ### Access level
35
35
 
36
- Each account carries `accessLevel: 'full' | 'workspace' | 'readonly' | 'observer'` (default `readonly`):
36
+ Each account carries `accessLevel: 'full' | 'workspace' | 'readonly'` (default `readonly`; the route validates against `CHAT_ACCESS_LEVELS`, so `observer` — web-only — is rejected):
37
37
  - `full` — no restrictions (no sandbox)
38
38
  - `workspace` — tool execution runs inside a bwrap sandbox with the workspace mounted read-write
39
39
  - `readonly` — tool execution runs inside a bwrap sandbox with the workspace mounted read-only; readonly sessions only receive read-only tools
@@ -46,7 +46,7 @@ WeCom bot accounts are stored in the unified channel DB: `~/.halo/secrets/channe
46
46
 
47
47
  WeCom-specific config JSON fields: `botId`, `secret`, `lastActiveChatId`.
48
48
 
49
- - `lastActiveChatId` is the raw `userid` (single) or `chatid` (group) of the most recent inbound message, written by `rememberLastActiveChat()` in `channels/shared/accounts.ts` (idempotent). Not consumed by cron dispatch (the dispatcher requires an explicit `chatId`).
49
+ - `lastActiveChatId` is the raw `userid` (single) or `chatid` (group) of the most recent inbound message, written by `rememberLastActiveChat()` in `channels/shared/accounts.ts` (idempotent). Not consumed by cron dispatch (the dispatcher requires an explicit `chatId`); read once at `startAccount` to restore the reply route (see [Reply route after a restart](#reply-route-after-a-restart)).
50
50
  - `secret` is never returned by `GET /api/wecom/accounts` and is not patchable — a fresh POST with the same `botId` rotates it.
51
51
 
52
52
  Inbound media lands under `<workspace>/.halo/assets/wecom/inbound/<accountId>/<date>/`.
@@ -67,7 +67,7 @@ The wecom cron-dispatcher (`channels/wecom/cron-dispatcher.ts`) requires an expl
67
67
 
68
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).
69
69
 
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`) is anchored to an inbound `req_id`; wiring cron up means switching it to `sendMediaMessage(chatid, …)` and flipping `supportsMedia: true`.
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
 
72
72
  ## Modules
73
73
 
@@ -97,6 +97,7 @@ Session-prefix kind `'wecom'` is registered in `channels/shared/session-prefix.t
97
97
  2. Server stores the account, calls `stopAccount` (idempotent) then `startAccount` — the stop must fully `disconnect()` before the new socket subscribes, or the new connection would kick its own predecessor
98
98
  3. `startAccount` builds `new WSClient({ botId, secret, maxReconnectAttempts: -1, maxAuthFailureAttempts: 3, logger })` and calls `connect()` (synchronous; auth happens on `authenticated`). The SDK handles heartbeat (30 s ping), reconnect with exponential back-off (infinite on network drops), and the auth retry budget
99
99
  4. `authenticated` → `liveClients.set(accountId, client)`; `event.disconnected_event` (kicked) or `error` with `WS_AUTH_FAILURE_EXHAUSTED` → client dropped, `liveClients.delete`
100
+ Before `connect()`, `startAccount` restores the reply route from `lastActiveChatId` (see [Reply route after a restart](#reply-route-after-a-restart))
100
101
  5. `stopAccount` = `bridge.closeAll()` (drains queued chunks, then releases reply routes) → `wsClient.disconnect()` → `liveClients.delete` → state removed
101
102
  6. On graceful shutdown the descriptor's `shutdown` calls `stopAll()`
102
103
 
@@ -110,11 +111,23 @@ All frames are JSON `{ cmd, headers: { req_id }, body }`; responses echo `header
110
111
  | `ping` / pong | client → server | SDK heartbeat |
111
112
  | `aibot_msg_callback` | server → client | inbound message → `'message'` event |
112
113
  | `aibot_event_callback` | server → client | events; only `disconnected_event` is handled (kicked). `enter_chat` / `template_card_event` / `feedback_event` are ignored |
113
- | `aibot_respond_msg` | client → server | `replyStream(frame, streamId, content, finish)` — every reply |
114
- | `aibot_send_msg` | client → server | `sendMessage(chatid, { msgtype: 'markdown' })` — cron only |
114
+ | `aibot_respond_msg` | client → server | `replyStream(frame, streamId, content, finish)` — replies with a live `req_id` |
115
+ | `aibot_send_msg` | client → server | `sendMessage(chatid, { msgtype: 'markdown' })` / `sendMediaMessage(chatid, …)` — cron, and replies on a route restored at startup |
115
116
  | `aibot_upload_media_init` / `_chunk` / `_finish` | client → server | `uploadMedia(buffer, { type, filename })` — chunked (≤ 512 KB × ≤ 100), returns `media_id` (valid 3 days) |
116
117
 
117
- **Reply model.** A stream reply is keyed to the callback's `req_id` and carries its own `stream.id`; the same `stream.id` can be re-sent to update the bubble until `finish: true`. Halo does **not** stream: every flushed chunk / hint / command reply is sent as a *new* `stream.id` with `finish: true` in one go — one finished bubble each. This keeps the responder identical to the other channels and sidesteps the 10-minute cap on an open stream (measured from its first frame). Replies must reference the inbound `req_id`, so the reply route stored per session is `{ reqId, chatType, chatId }`, refreshed on every inbound message (the route is read lazily at send time, as in slack / feishu).
118
+ **Reply model.** A stream reply is keyed to the callback's `req_id` and carries its own `stream.id`; the same `stream.id` can be re-sent to update the bubble until `finish: true`. Halo does **not** stream: every flushed chunk / hint / command reply is sent as a *new* `stream.id` with `finish: true` in one go — one finished bubble each. This keeps the responder identical to the other channels and sidesteps the 10-minute cap on an open stream (measured from its first frame). Replies must reference the inbound `req_id`, so the reply route stored per session is `{ reqId, chatId }`, refreshed on every inbound message (the route is read lazily at send time, as in slack / feishu). An empty `reqId` (restored route, below) switches `replyText` / `sendWecomMedia` to the proactive push.
119
+
120
+ ### Reply route after a restart
121
+
122
+ Like the other channels, `startAccount` calls `restoreReplyRoute` → `restoreChannelRoute` (`channels/shared/inbound.ts`) before connecting, so a session that resumes on its own after a restart (run-ledger nudge, queued turn, admin-typed message) still reaches WeCom before the user writes again. It reads `lastActiveChatId`, takes the latest existing root session under `wecom_<normalizeWecomId(chatId)>_` (goal-routed, never creates one) and wires route `{ reqId: '', chatId }` + listener. Both single chats and groups restore: the cached raw id maps 1:1 onto the session key, unlike telegram (a group id doesn't name the member) or feishu (a group thread reply needs the inbound message id).
123
+
124
+ What is **not** restored, and why:
125
+
126
+ - **Passive replies.** The inbound `req_id` lived only in the old process's memory (and is only valid ~10 minutes anyway), so the restored route has none. Text goes out as `aibot_send_msg` markdown, media as `uploadMedia` + `sendMediaMessage`. That is a push, not a stream reply: the bubble isn't attached to the user's message, and the push channel's own rules apply — it is refused for a user / group that has never messaged the bot, and counts against the 30 msgs/min · 1000/hour per-conversation limit.
127
+ - **Only the last active chat.** One `lastActiveChatId` per account → at most one session restored; other users' sessions wait for their next message (same as telegram / slack / feishu).
128
+ - **A connected socket.** The route is wired before `connect()`. A send that fires before the socket is open is rejected by the SDK (`WebSocket not connected`) and logged as `[WeCom] sendText failed`; after a kick / auth exhaustion there is no `wsClient` and the send is skipped — same as for any route. In practice the resumed turn's model call outlasts the ~1 s auth handshake.
129
+
130
+ The next inbound message from that chat replaces the restored route with a fresh `req_id`, and replies go back to passive streaming.
118
131
 
119
132
  ## Message handling flow
120
133
 
@@ -147,18 +160,19 @@ Stream `content` is capped at 20480 **bytes**. The responder splits at 5000 **ch
147
160
  - `MEDIA:<path>` markers intercepted and sent via native media upload
148
161
  - Sub-agent events dropped (visible in web UI only)
149
162
 
150
- Chunk sends are serialized per responder (`sendTail` promise chain, audit A-L3) and `close()` returns the drain promise so `InboundBridge` keeps the reply route alive until the last chunk is out — identical to the feishu adapter, see [feishu.md](feishu.md#event-coalescing-feishuresponder) for the history.
163
+ Chunk sends are serialized per responder (`sendTail` promise chain, audit A-L3) and `close()` returns the drain promise so `InboundBridge` keeps the reply route alive until the last chunk is out — both come from the shared `ChunkedResponder` (`channels/shared/responder.ts`) that the feishu adapter also extends, see [feishu.md](feishu.md#event-coalescing-feishuresponder) for the history.
151
164
 
152
165
  ## Media support
153
166
 
154
167
  **Inbound:** text, voice (as transcript), images (vision + saved), mixed (text + images), files, videos — single chat only for everything but text. Saved under `<workspace>/.halo/assets/wecom/inbound/<accountId>/<date>/`.
155
168
 
156
- **Outbound routing** (`sendWecomMedia`; for non-`full` accounts the path must be under the workspace or the OS temp dir — `isMediaPathAllowed(path, workspace, accessLevel)`, `full` accounts may send any readable path; a blocked path surfaces as the same `t('handler.upload_failed')` reply as a failed upload):
169
+ **Outbound routing** (`sendWecomMedia`; for non-`full` accounts the path must be under the workspace or the OS temp dir — `isMediaPathAllowed(path, workspace, accessLevel)`, `full` accounts may send any readable path; a blocked path surfaces as the same `t('handler.upload_failed')` reply as a failed upload — `sendMediaOrReport` in `channels/shared/media.ts`):
157
170
  - `.png` / `.jpg` / `.jpeg` / `.gif` → `uploadMedia(type: 'image')` → `replyMedia('image')`
158
171
  - `.mp4` → `uploadMedia(type: 'video')` → `replyMedia('video')`
159
172
  - everything else (`.webp` / `.bmp` / `.mov` / documents / audio) → `uploadMedia(type: 'file')` → `replyMedia('file')` — WeCom's `image` accepts png/jpg/gif only and `video` mp4 only
160
173
  - > 20 MB → refused; the user sees `t('handler.upload_failed')` (`⚠️ 文件上传失败:name — error`)
161
174
  - Voice is never sent: WeCom expects AMR, which the agent doesn't produce
175
+ - On a route restored at startup (no `req_id` yet) every `replyMedia(...)` above becomes `sendMediaMessage(chatId, type, mediaId)` — the proactive push
162
176
 
163
177
  ## Similarities vs Feishu and Telegram
164
178
 
@@ -212,6 +226,6 @@ Not supported: streaming partial replies into WeCom (each reply is one finished
212
226
  - Event dispatch: `packages/server/src/channels/wecom/handler.ts:handleInbound()`
213
227
  - Media upload: `packages/server/src/channels/wecom/handler.ts:sendWecomMedia()`
214
228
  - Cron dispatch: `packages/server/src/channels/wecom/cron-dispatcher.ts:dispatch()`
215
- - Event coalescing: `packages/server/src/channels/wecom/event-adapter.ts:WecomResponder`
229
+ - Event coalescing: `packages/server/src/channels/wecom/event-adapter.ts:WecomResponder` (extends `channels/shared/responder.ts:ChunkedResponder`)
216
230
  - REST routes: `packages/server/src/routes/wecom.ts`
217
231
  - Tests: `packages/server/test/wecom-inbound.test.ts` (pure helpers), `wecom-responder.test.ts` (chunking / ordering / markdown pass-through)
@@ -48,7 +48,7 @@ Liveness probes bound how long a zombie lives, but a chat sent *into* the zombie
48
48
 
49
49
  - **Client** ([ws-client.ts](../../../packages/admin/src/shared/ws-client.ts)): every chat carries a client-generated `clientMsgId` and enters a pending-ack table (capacity 100 — past that the oldest entry is failed visibly) before transmission. Transmission is gated on freshness: if inbound has been silent > 30 s, the socket is torn down first and the chat rides the reconnect instead of being trusted to a suspect link. **5 s without `chat:ack`** → force-close, and the `onopen` flush retransmits every unacked entry in insertion order. After **3 transmit attempts** the client stops tearing the link down on that chat's behalf (rapid network flaps would otherwise turn one chat into endless connection churn) but the entry stays pending and still rides every flush. A **2 min wall-clock deadline** per entry is the final arbiter: every chat terminates either in an ack or in a visible `_chat_send_failed` (red "send failed" badge on the bubble + its empty placeholder converged) — no infinite lurking.
50
50
  - **Server** ([handler.ts](../../../packages/server/src/ws/handler.ts)): ack semantics = *the message is in the session log*. Each `handleChat` branch calls `rememberChat` **synchronously right after `appendUserMessage`** (no `await` in between — an await there opens a double-append window against a racing resend), then replies `{type:'chat:ack', clientMsgId}` once enqueueing is done. Resends are deduped against a handler-scoped FIFO of the last 500 acked ids (handler-scoped because a resend arrives on a *new* connection): a duplicate is re-acked without re-appending. At-least-once resend + server dedup ≈ exactly-once in the session log.
51
- - **Indicator**: the connection light is tri-state (`fresh` / `stale` / `down`, [use-websocket.ts](../../../packages/admin/src/shared/use-websocket.ts)) driven by inbound-traffic age rather than last-known socket state — `stale` (amber) at > 45 s of silence (3× probe interval), `down` (red) on close. A zombie link can no longer sit green.
51
+ - **Indicator**: the connection indicator (the network segment of the activity bar's quick-toggles entry) is tri-state (`fresh` / `stale` / `down`, [use-websocket.ts](../../../packages/admin/src/shared/use-websocket.ts)) driven by inbound-traffic age rather than last-known socket state — `stale` (amber) at > 45 s of silence (3× probe interval), `down` (red) on close. A zombie link can no longer sit green.
52
52
  - **Known boundary (accepted)**: the dedup FIFO is in-memory. If the server restarts inside the narrow window between "ack sent but lost in flight" and the client's resend, the new process re-appends the message and the agent runs it twice. The overlap is seconds-rare, and persisting the table would put a synchronous disk write on the chat hot path — not worth it unless observed in practice.
53
53
 
54
54
  ## Client → Server
@@ -86,7 +86,7 @@ Optional `chat` fields:
86
86
 
87
87
  ## Server → Client
88
88
 
89
- Source: [event-processor.ts:53-129](../../../packages/server/src/ws/event-processor.ts#L53) `sendWsNotification` switch.
89
+ Source: [event-processor.ts](../../../packages/server/src/ws/event-processor.ts) `sendWsNotification` switch.
90
90
 
91
91
  ### Agent event → WS message mapping
92
92
 
@@ -102,7 +102,7 @@ Source: [event-processor.ts:53-129](../../../packages/server/src/ws/event-proces
102
102
  | `tool_result` | `agent:tool_result` | result, toolUseId, agentName, taskId, durationMs, sessionId |
103
103
  | `followup_start` / `queued_message` | `chat:followup` | agentName, sessionId |
104
104
  | `usage` (no taskId) | `chat:usage` | contextTokens, outputTokens, turnId, modelId, usage, sessionId |
105
- | `complete` | `chat:complete` | sessionId |
105
+ | `complete` | `chat:complete` | sessionId, batchBoundary (only between drained queued turns; the admin closes bubbles but doesn't ring the finish bell) |
106
106
  | `context` | `agent:context` | agentName, systemPrompt, taskId, sessionId |
107
107
  | `system` | `chat:system` | text, taskId, agentName, sessionId |
108
108
  | `error` | `error` | error, agentName, taskId, sessionId |
@@ -118,7 +118,7 @@ Every provider streams (Bedrock, the Anthropic-Messages providers anthropic / mi
118
118
 
119
119
  `chat:system` producers (`session-manager.ts`'s `stop` event handling): a `max_tokens` stop emits `⚠️ [<agent>] Response truncated: output token limit reached.`; a `refusal` stop (Anthropic `stop_reason: "refusal"`, HTTP 200 — the model declined, not an error) emits `⚠️ [<agent>] Model declined to respond (<category>): <explanation> — …` suggesting `/new` (see [session.md](session.md#resilient-execution-loop)).
120
120
 
121
- Server-internal flags on `AgentSessionEvent` that are **not** carried into the WS frame: `stream.final` (marks the turn's wrap-up text vs. pre-tool filler — consumed by channel responders and the cli, see [session.md](session.md#message-queue-and-drain)), `stream.streamed` / `thinking.streamed` (the text already went out as deltas — decides whether the whole event is forwarded at all) and `complete.batchBoundary`. The admin renders every streamed block, so none is needed on the wire.
121
+ Server-internal flags on `AgentSessionEvent` that are **not** carried into the WS frame: `stream.final` (marks the turn's wrap-up text vs. pre-tool filler — consumed by channel responders and the cli, see [session.md](session.md#message-queue-and-drain)), `stream.streamed` / `thinking.streamed` (the text already went out as deltas — decides whether the whole event is forwarded at all). The admin renders every streamed block, so none is needed on the wire.
122
122
 
123
123
  ### Other Server → Client messages
124
124
 
@@ -231,11 +231,11 @@ Client reconnects and sends `subscribe`:
231
231
 
232
232
  ### Double-subscribe guard
233
233
 
234
- Inside the subscribe handler, `messageLog.length === 0` is a precondition for loading from file — so if reattach has already populated the log, subsequent subscribes can't overwrite it with stale file data. Prevents two consecutive subscribes from losing state.
234
+ The reattach path reads the cached UIState (`getCachedUIState`), never disk. A plain subscribe goes through `getSessionView` → `prepareForView`, which keeps the cached state while the session is live in this process (self-driven, or active work in its tree) and re-seeds from disk otherwise — so a repeat subscribe can't overwrite in-flight state with stale file data.
235
235
 
236
236
  ### Client-side reconnect reconciliation
237
237
 
238
- The chat stream reconciles itself (above), but several admin panels keep state in sync purely from incremental `file:changed` / bus deltas — events emitted while the socket was down are lost forever, leaving them stale until an unrelated event arrives, or indefinitely when none does. Every such subscriber pairs its delta subscription with `onWsReconnect(wsClient, <its existing refetch>)` ([ws-reconnect.ts](../../../packages/admin/src/shared/ws-reconnect.ts)) — a reconnect re-reads from the server instead of trusting the gap. Current consumers: file tree (`use-file-tree.ts` → `loadFileTree`, silent replace), editor open tabs (`editor-panel.tsx` → `refreshActiveTab`), git decorations, Source Control panel + history graph, skills sidebar, agent-management list, agent session-chat panel, and the session-list bus (`state-handlers.ts`).
238
+ The chat stream reconciles itself (above), but several admin panels keep state in sync purely from incremental `file:changed` / bus deltas — events emitted while the socket was down are lost forever, leaving them stale until an unrelated event arrives, or indefinitely when none does. Every such subscriber pairs its delta subscription with `onWsReconnect(wsClient, <its existing refetch>)` ([ws-reconnect.ts](../../../packages/admin/src/shared/ws-reconnect.ts)) — a reconnect re-reads from the server instead of trusting the gap. Current consumers: file tree (`use-file-tree.ts` → `loadFileTree`, silent replace), editor open tabs (`editor-panel.tsx` → `refreshActiveTab`), git decorations, Source Control panel + history graph, skills sidebar, agent-management list, agent session-chat panel, the session-list bus and the preview-extension list (`state-handlers.ts`).
239
239
 
240
240
  `everConnected` seeds from `client.connected`, which makes "reconnect" mean the same thing for both mount timings: a panel mounting on an already-open socket treats the next `_connected` as a reconnect, while a page-load mount does **not** fire on its first `_connected` (the subscriber's own mount fetch already covered it — firing would be a pure double-pull).
241
241
 
@@ -34,7 +34,7 @@ Claude Code on a developer's laptop wants to talk to a halo agent running in an
34
34
  --port 9527 \
35
35
  --token <web-token-from-step-1> \
36
36
  --workspace /abs/path/on/server \
37
- --agent-id default # optional; falls back to 'default'
37
+ --agent-id default # optional; omitted = the workspace's entry agent
38
38
  ```
39
39
 
40
40
  3. The adapter writes JSON-RPC frames to stdout (1 message per line) and reads stdin the same way. Stderr is reserved for human-readable diagnostics — do not parse.
@@ -80,7 +80,7 @@ To remove a binding: `/acp remove` (deletes the skill directory and points out t
80
80
  | `--scheme` | no | URL scheme — `http` or `https` (default `http`). Use `https` when the server sits behind a TLS reverse proxy. Invalid values are rejected at the adapter boundary. |
81
81
  | `--token` | yes | Web-channel token from admin UI. `full` access required for multi-workspace use. |
82
82
  | `--workspace` | yes | Absolute server-side path for the workspace this adapter drives. |
83
- | `--agent-id` | no | Halo agent profile to use when ACP `session/new` creates a new halo session. Default: `default`. Alias: `--agent` (matches the main CLI's `--agent`). |
83
+ | `--agent-id` | no | Halo agent profile to use when ACP `session/new` creates a new halo session. Must be a non-internal, non-disabled agent (else `session/new` fails with `agent not available`). Default: the workspace's entry agent — the highest-`priority` non-disabled, non-internal agent, i.e. `default` out of the box. Alias: `--agent` (matches the main CLI's `--agent`). |
84
84
  | `--header` | no | Extra HTTP header on every upstream request, `"Name: value"` (like `curl -H`). Repeatable. For auth that sits **in front of** the halo server — see "Upstream auth" below. |
85
85
 
86
86
  One adapter process binds to one workspace. To drive multiple workspaces concurrently from the same token, run multiple adapter processes — see "Multi-workspace" below.
@@ -89,7 +89,7 @@ One adapter process binds to one workspace. To drive multiple workspaces concurr
89
89
 
90
90
  `--token` authenticates the adapter to *halo itself* (it becomes the `x-token` header on every web-channel call). It does **not** cover a proxy sitting in front of the server — an SSO/session-cookie gateway, Cloudflare Access, ALB OIDC, nginx basic-auth. Those reject the request before halo ever sees the token.
91
91
 
92
- `--header` is the generic escape hatch: it forwards arbitrary headers on every request the adapter makes (`/api/web/chat`, `/history`, `/stop`), exactly like `curl -H`. The adapter deliberately knows nothing about any specific gateway — you supply whatever that layer wants:
92
+ `--header` is the generic escape hatch: it forwards arbitrary headers on every request the adapter makes (`/api/web/sessions`, `/chat`, `/history`, `/stop`), exactly like `curl -H`. The adapter deliberately knows nothing about any specific gateway — you supply whatever that layer wants:
93
93
 
94
94
  ```sh
95
95
  # session-cookie gateway
@@ -137,8 +137,8 @@ This keeps the adapter stateless on disk — losing the in-memory map on restart
137
137
  | `session` | (latched internally) | First-frame echo of the resolved sessionId. Adapter records it; not surfaced. |
138
138
  | `stream` (assistant text) | `agent_message_chunk` | Forwarded as `content: { type: 'text', text }`. |
139
139
  | `thinking` | `agent_thought_chunk` | Same shape as message chunk. |
140
- | `tool_call` | `tool_call` (status: in_progress) | Adapter mints a stable `toolCallId`. `kind: 'other'` because halo doesn't categorize tools. |
141
- | `tool_result` | `tool_call_update` (status: completed) | Pairs by *order* with the most recent `tool_call` — halo's `tool_result` event doesn't carry the tool name, but it's emitted in lockstep with its call. |
140
+ | `tool_call` | `tool_call` (status: in_progress) | Adapter mints a stable `toolCallId`; the halo `toolInput` goes out as `rawInput`. `kind: 'other'` because halo doesn't categorize tools. |
141
+ | `tool_result` | `tool_call_update` (status: completed) | Pairs by *order* with the most recent `tool_call` (the frame's `toolName`, when present, must match it). A result with no matching call is sent as a self-contained `tool_call` + `tool_call_update`. The web channel truncates `result` to 500 chars. |
142
142
  | `file` | `agent_message_chunk: [file: …]` | The file lives on the server; without reverse fs we can only point at it textually. |
143
143
  | `error` | `agent_message_chunk: [error] …` then end | Ends the prompt response with `stopReason: 'end_turn'` (we treat agent errors as a normal end-of-turn for protocol purposes). |
144
144
  | `queued` | `agent_message_chunk: [queued — session busy]` | Halo queues messages when the session is busy. Adapter ends the response. |
@@ -150,7 +150,7 @@ This keeps the adapter stateless on disk — losing the in-memory map on restart
150
150
 
151
151
  A single web-channel token in halo is bound to one workspace at the database level. The adapter works around this by using the per-request `workspace` + `sessionId` overrides on `/api/web/*`:
152
152
 
153
- - `/api/web/chat`, `/api/web/stop`, `/api/web/history`, `/api/web/subscribe` accept `workspace=<path>` and `sessionId=<id>` (query params, headers `x-workspace` / `x-session-id`, or POST body fields).
153
+ - `/api/web/chat`, `/api/web/stop`, `/api/web/history`, `/api/web/subscribe` accept `workspace=<path>` and `sessionId=<id>` (query params, headers `x-workspace` / `x-session-id`, or POST body fields); `/api/web/sessions` accepts `workspace` and `agentId` the same way.
154
154
  - Server gates the workspace override on `accessLevel === 'full'` — readonly / workspace tokens cannot escape their account-bound workspace.
155
155
  - The adapter sends both fields on every request, so concurrent adapters on the same token but different `--workspace` flags don't step on each other.
156
156
 
@@ -172,7 +172,7 @@ For now: if the user wants the agent to see a Mac-side file, they paste it into
172
172
  Code is in `packages/acp-adapter/`:
173
173
 
174
174
  - `src/jsonrpc.ts` — minimal newline-delimited JSON-RPC 2.0 peer over stdio. No LSP-style Content-Length framing — ACP uses one JSON object per line.
175
- - `src/halo-client.ts` — wraps `POST /api/web/chat` (SSE), `POST /api/web/stop`. Parses `data: <json>\n\n` frames into JS objects.
175
+ - `src/halo-client.ts` — wraps `POST /api/web/sessions`, `POST /api/web/chat` (SSE), `POST /api/web/stop` and `GET /api/web/history`. Parses `data: <json>\n\n` frames into JS objects.
176
176
  - `src/adapter.ts` — registers the ACP method handlers, owns the per-session state (`Map<sessionId, { workspace, lastToolCall, promptAbort }>` — `sessionId` is shared with halo), translates SSE events to `session/update` notifications.
177
177
  - `src/index.ts` — CLI argv parsing, wires stdin/stdout to a `JsonRpcConnection`, instantiates the adapter.
178
178
 
@@ -180,7 +180,7 @@ CLI integration is in `@turmind/halo-cli`'s `index.ts` `cmd === 'acp'` branch
180
180
 
181
181
  ## Testing
182
182
 
183
- No automated suite yet — verification is by manual smoke. The cases below cover the protocol surface and the realistic end-to-end shape (Claude Code → ACP adapter → halo server → remote agent). When you change adapter / web-channel / settings code, walk this list.
183
+ `packages/acp-adapter/test/` holds vitest unit tests for the JSON-RPC framing and SSE parsing (`pnpm --filter @turmind/halo-acp-adapter test`); the adapter ↔ server flow has no automated suite and is verified by manual smoke. The cases below cover the protocol surface and the realistic end-to-end shape (Claude Code → ACP adapter → halo server → remote agent). When you change adapter / web-channel / settings code, walk this list.
184
184
 
185
185
  ### Setup
186
186
 
@@ -28,7 +28,7 @@ What you write yourself:
28
28
 
29
29
  ## Reference implementation
30
30
 
31
- Whole directory: [packages/server/src/channels/wechat/](../../../packages/server/src/channels/wechat/) (~1900 lines across 9 files).
31
+ Whole directory: [packages/server/src/channels/wechat/](../../../packages/server/src/channels/wechat/) (~1800 lines across 10 files).
32
32
 
33
33
  The pattern that works:
34
34
 
@@ -88,6 +88,7 @@ All channel accounts live in a single unified table: `~/.halo/secrets/channels/c
88
88
  2. Create `accounts.ts` as a thin adapter over `channels/shared/accounts.ts`:
89
89
  - Map `ChannelAccount.config` JSON → your typed interface
90
90
  - Pass `channelType = 'slack'` to all shared DAL calls
91
+ - Write through `insertChannelAccount(db, 'slack', data, config)` / `updateChannelAccount(db, id, patch, configKeys)` — the shared helpers own the row columns + config-JSON merge; your file only lists its config keys
91
92
 
92
93
  Reference adapters: [channels/telegram/accounts.ts](../../../packages/server/src/channels/telegram/accounts.ts), [channels/web/accounts.ts](../../../packages/server/src/channels/web/accounts.ts).
93
94
 
@@ -101,11 +102,11 @@ Inbound: Slack pushes events via **HTTPS webhooks** (Events API) or **WebSocket*
101
102
 
102
103
  ### 4. Write the main handler
103
104
 
104
- `handler.ts`: exports `startSlackChannel(deps: { registry, db })` returning `{ startAccount, stopAccount, stopAll }`. WeChat shape at [packages/server/src/channels/wechat/handler.ts:73-157](../../../packages/server/src/channels/wechat/handler.ts#L73-L157).
105
+ `handler.ts`: exports `startSlackChannel(deps: { registry, db })` returning `{ startAccount, stopAccount, stopAll }`. WeChat shape: `startWechatChannel` in [packages/server/src/channels/wechat/handler.ts](../../../packages/server/src/channels/wechat/handler.ts).
105
106
 
106
107
  For webhook-style channels (Slack Events API), you don't need a `runAccountLoop` — instead expose an HTTP route that validates the signature, deserializes the event, and calls `handleInbound`. For Socket Mode, you **do** have a loop (the WebSocket reconnect loop).
107
108
 
108
- Inside `handleInbound`, **don't hand-roll the session/listener plumbing** — the get-or-create, access folding, busy hint, responder registration and agent-input assembly all live in [channels/shared/inbound.ts](../../../packages/server/src/channels/shared/inbound.ts) (`InboundBridge` + `deliverInbound` + `dispatchChannelCommand`). All four channels go through it; the four hand-written copies it replaced are how audit findings A-M2 (responder locked onto the first user in a chat) and A-M5 (skill-command turns ran with no listener attached) happened. Your handler parses the platform message and supplies a **reply route**:
109
+ Inside `handleInbound`, **don't hand-roll the session/listener plumbing** — the get-or-create, access folding, busy hint, responder registration and agent-input assembly all live in [channels/shared/inbound.ts](../../../packages/server/src/channels/shared/inbound.ts) (`InboundBridge` + `deliverInbound` + `dispatchChannelCommand`). All five IM channels go through it; the hand-written copies it replaced are how audit findings A-M2 (responder locked onto the first user in a chat) and A-M5 (skill-command turns ran with no listener attached) happened. Your handler parses the platform message and supplies a **reply route**:
109
110
 
110
111
  ```ts
111
112
  // One bridge per running account, owned by the account state so
@@ -146,14 +147,14 @@ async function handleInbound({ registry, db, account, event, bridge, activeOverr
146
147
 
147
148
  ### 5. Write the event adapter
148
149
 
149
- `event-adapter.ts`: buffers streamed text and sends whole messages. WeChat's flushes on either a 3500-char hard ceiling (`WECHAT_TEXT_LIMIT`, the ilink gateway rejects >16 KB) or a `complete` event; see [packages/server/src/channels/wechat/event-adapter.ts](../../../packages/server/src/channels/wechat/event-adapter.ts). Use the shared `splitText(text, limit)` from `channels/shared/chunk.ts` for the cut (paragraph boundary, then hard-cut) — all four existing responders do; don't write a private splitter. Slack has a 40k char limit but users dislike huge messages, so it splits at 35k. Serialize your sends through one promise chain per responder (see wechat's `sendTail`) and return it from `close()` so the bridge keeps the route alive until the tail has gone out.
150
+ `event-adapter.ts`: buffers streamed text and sends whole messages. WeChat's flushes on either a 3500-char hard ceiling (`WECHAT_TEXT_LIMIT`, the ilink gateway rejects >16 KB) or a `complete` event; see [packages/server/src/channels/wechat/event-adapter.ts](../../../packages/server/src/channels/wechat/event-adapter.ts). Use the shared `splitText(text, limit)` from `channels/shared/chunk.ts` for the cut (paragraph boundary, then hard-cut) — every existing responder does; don't write a private splitter. Slack has a 40k char limit but users dislike huge messages, so it splits at 35k. Block-oriented channels extend `ChunkedResponder` from `channels/shared/responder.ts` (all five existing channels do), passing `{ limit, logTag, format?, splitMidStream? }`: it buffers `final` text, splits with `splitText` (mid-stream too with `splitMidStream`, as telegram / wechat do), serializes sends through one `sendTail` promise chain and returns it from `close()` so the bridge keeps the route alive until the tail has gone out. Override `onSendFailed` only for a real channel quirk (wechat's `onSendError`).
150
151
 
151
152
  Its send primitives take **no destination argument** — they read `bridge.getRoute(sessionId)` on each send (see step 4). A responder that captured a chat/user id at construction time is the A-M2 bug.
152
153
 
153
154
  **What to forward, what to drop**:
154
155
  - Drop `tool_call` / `tool_result` / `thinking` events — they're chatter the user doesn't want in IM
155
156
  - Drop events where `event.taskId` is set (those are sub-agent events; the root agent's text is enough)
156
- - Forward `stream` text **only when `event.final` is set** — that's the turn's closing reply; the filler the model emits before a tool call ("let me check…") is chatter in IM too. `error` goes out immediately with a `[error]` prefix; `complete` flushes the remaining buffer
157
+ - Forward `stream` text **only when `event.final` is set** — that's the turn's closing reply; the filler the model emits before a tool call ("let me check…") is chatter in IM too. `error` goes out immediately with a `❌ ` prefix (`system` with `ℹ️ `); `complete` flushes the remaining buffer
157
158
  - Forward `system` if the platform can render it (e.g. Slack ephemeral messages)
158
159
 
159
160
  ### 6. Surface channel context to skills
@@ -235,7 +236,7 @@ export function createSlackRoutes(deps: { db, channel }) {
235
236
  }
236
237
  ```
237
238
 
238
- Pattern: [packages/server/src/routes/wechat.ts](../../../packages/server/src/routes/wechat.ts).
239
+ Pattern: [packages/server/src/routes/wechat.ts](../../../packages/server/src/routes/wechat.ts). Shared CRUD pieces live in [packages/server/src/routes/channel-accounts.ts](../../../packages/server/src/routes/channel-accounts.ts): `accountListFields` (common GET row, no credentials), `accountBodyError` (accessLevel + workspacePath checks) and `accountPatchFromBody` (row part of a PATCH); credentials, required fields, id derivation and restart policy stay in your route file.
239
240
 
240
241
  ### 8. Write the server descriptor + register
241
242
 
@@ -275,7 +276,7 @@ That's all the server wiring. `index.ts` calls `bootChannels(app, defaultChannel
275
276
 
276
277
  To let users manage Slack accounts from the web UI:
277
278
 
278
- 1. Build the React component (mirror [packages/admin/src/features/wechat/wechat-settings.tsx](../../../packages/admin/src/features/wechat/wechat-settings.tsx)). Place it at `packages/admin/src/features/slack/slack-settings.tsx`.
279
+ 1. Build the settings page at `packages/admin/src/features/slack/slack-settings.tsx` on the shared pieces in `packages/admin/src/features/channels/`: `useChannelAccounts` + `ChannelAccountsPage` + `ChannelAccountRow` ([account-list.tsx](../../../packages/admin/src/features/channels/account-list.tsx)) for the list, header and inline edit, and `ChannelAddDialog` ([account-form.tsx](../../../packages/admin/src/features/channels/account-form.tsx)) for the add dialog. The page keeps only what's channel-specific: the account type, the row's name / detail line, the credential inputs, and the `api.slack.*` calls. Mirror [packages/admin/src/features/wecom/wecom-settings.tsx](../../../packages/admin/src/features/wecom/wecom-settings.tsx). The shared parts read i18n keys from the channel's own namespace (`slack.title`, `slack.add`, `slack.addTitle`, `slack.addBtn`, `slack.confirmDelete`, …). The full key list is in the header comments of the two files.
279
280
  2. Create `packages/admin/src/features/slack/descriptor.ts`:
280
281
  ```ts
281
282
  import { Hash } from 'lucide-react' // or whatever icon fits
@@ -345,7 +346,7 @@ Channel-specific commands (e.g. WeChat's `/qr`) go in a fallback switch after `d
345
346
 
346
347
  ### Compact / busy states
347
348
 
348
- Handled for you inside `deliverInbound`: it calls `busyHint(sm, sessionId, lang)` ([channels/shared/busy-hint.ts](../../../packages/server/src/channels/shared/busy-hint.ts)) and sends the result through your `sendHint` callback. The hint is a **hint only** — the message is always delivered, because `sendUserMessage` queues compacting/busy sessions itself. (The four inlined copies once carried an extra `return` in the compacting branch, so messages sent mid-compact were answered with a hint and then silently dropped.)
349
+ Handled for you inside `deliverInbound`: it calls `busyHint(sm, sessionId, lang)` ([channels/shared/busy-hint.ts](../../../packages/server/src/channels/shared/busy-hint.ts)) and sends the result through your `sendHint` callback. The hint is a **hint only** — the message is always delivered, because `sendUserMessage` queues compacting/busy sessions itself. (The inlined per-channel copies once carried an extra `return` in the compacting branch, so messages sent mid-compact were answered with a hint and then silently dropped.)
349
350
 
350
351
  ### Single-instance lock
351
352
 
@@ -361,13 +362,15 @@ At handler startup, `resolveAccountWorkspace(account)` checks the path exists on
361
362
 
362
363
  Use the shared helper `saveInboundMedia({ workspacePath, accountId, channel: 'slack', buffer, kind: 'image', mimeType })` — [packages/server/src/channels/shared/media-store.ts](../../../packages/server/src/channels/shared/media-store.ts). It saves under `<ws>/.halo/assets/slack/inbound/<accountId>/<date>/` and returns the path. Append `[图片已保存: /abs/path]` to the agent's input text and the existing UI code will render a thumbnail.
363
364
 
365
+ Outbound `MEDIA: <path>` markers go through `sendMediaOrReport({ filePath, account, logTag, send, reply })` ([channels/shared/media.ts](../../../packages/server/src/channels/shared/media.ts)): it runs the access-level path check (`assertMediaPathAllowed`), then your `send()` upload, and reports a block or failed upload back to the chat. A responder that already has its own failure channel can call `assertMediaPathAllowed` directly and let the throw ride `onSendError` (WeChat does this).
366
+
364
367
  For images going to the LLM, also pass them as base64 in the `images` arg — `sm.sendUserMessage(sid, text, images)`.
365
368
 
366
369
  ---
367
370
 
368
371
  ## Testing
369
372
 
370
- No automated test framework for channels — manual for now:
373
+ Shared channel plumbing has automated coverage under `packages/server/test/` (`channel-*.test.ts` for the inbound bridge, responder ordering / `final`-only forwarding, media whitelist, route restore, access-level and workspace validation; plus per-channel files such as `telegram-group-chat.test.ts`, `wecom-inbound.test.ts`, `wecom-responder.test.ts`). The platform round-trip itself is still manual:
371
374
 
372
375
  1. Create a test bot on the platform (development workspace / test server)
373
376
  2. Bind it to a throwaway Halo workspace: `POST /api/slack/accounts`