@turmind/halo 1.0.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/212-394c4fd40216e633.js +1 -0
- package/admin-out/_next/static/chunks/220.e5a2532bda77e816.js +1 -0
- package/admin-out/_next/static/chunks/442.68ed26e27eb1d84f.js +1 -0
- package/admin-out/_next/static/chunks/515.4d98fae302278913.js +1 -0
- package/admin-out/_next/static/chunks/896.ed21ec1e0846d20d.js +1 -0
- package/admin-out/_next/static/chunks/935.543aeec8456b6061.js +1 -0
- package/admin-out/_next/static/chunks/app/page-86cb00582129ae76.js +1 -0
- package/admin-out/_next/static/chunks/webpack-fc1574554e7b1108.js +1 -0
- package/admin-out/_next/static/css/0d0ca7b3c8cc5562.css +3 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/agent.md +11 -0
- package/bundled-docs/design/agentcore.md +10 -0
- package/bundled-docs/design/architecture.md +28 -29
- package/bundled-docs/design/background-dispatch.md +30 -43
- package/bundled-docs/design/cron.md +10 -1
- package/bundled-docs/design/evolution.md +18 -2
- package/bundled-docs/design/feishu.md +19 -6
- package/bundled-docs/design/goal-mode.md +2 -2
- package/bundled-docs/design/halo-city.md +7 -0
- package/bundled-docs/design/session.md +50 -2
- package/bundled-docs/design/slack.md +20 -6
- package/bundled-docs/design/storage.md +39 -2
- package/bundled-docs/design/telegram.md +20 -8
- package/bundled-docs/design/web.md +1 -0
- package/bundled-docs/design/wechat.md +13 -17
- package/bundled-docs/design/ws.md +35 -9
- package/bundled-docs/dev/add-channel.md +49 -37
- package/bundled-docs/dev/api.md +121 -35
- package/bundled-docs/dev/deploy.md +48 -0
- package/bundled-docs/dev/desktop-packaging.md +41 -7
- package/bundled-docs/dev/env.md +1 -0
- package/bundled-docs/dev/previews.md +58 -3
- package/bundled-docs/guide/channels/wechat.md +3 -3
- package/bundled-docs/guide/cli.md +24 -5
- package/bundled-docs/requirements/chat.md +11 -2
- package/bundled-docs/requirements/editor.md +5 -2
- package/bundled-docs/requirements/explorer.md +1 -0
- package/bundled-docs/requirements/session.md +4 -3
- package/bundled-docs/requirements/source-control.md +1 -0
- package/dist/index.js +14293 -13212
- package/package.json +4 -1
- package/templates/schema.sql +8 -10
- package/admin-out/_next/static/chunks/212-c5f046048be9c367.js +0 -1
- package/admin-out/_next/static/chunks/220.3dc41cd6269b88e6.js +0 -1
- package/admin-out/_next/static/chunks/442.f4302087bddb1ec1.js +0 -1
- package/admin-out/_next/static/chunks/app/page-5a6cf2b73a12b849.js +0 -1
- package/admin-out/_next/static/chunks/webpack-47520cdc55be63e7.js +0 -1
- package/admin-out/_next/static/css/62b8bf49e6404ffc.css +0 -3
- /package/admin-out/_next/static/{CWrUwTypkebkhwZ5i82_G → lOrGs0dR-jmXz9PgabRBc}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{CWrUwTypkebkhwZ5i82_G → lOrGs0dR-jmXz9PgabRBc}/_ssgManifest.js +0 -0
|
@@ -6,7 +6,7 @@ Let the user talk to Halo from Telegram, sharing the same workspace + sessions a
|
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
┌── ws/ (web channel) ─┐
|
|
9
|
-
├── channels/
|
|
9
|
+
├── channels/wechat/ │
|
|
10
10
|
Halo server (9527) ──┤ ├── SessionManager
|
|
11
11
|
└── channels/telegram/ ─┘ (per workspace, via Registry)
|
|
12
12
|
↕ grammy long-poll
|
|
@@ -15,6 +15,18 @@ Halo server (9527) ──┤ ├── SessionMa
|
|
|
15
15
|
|
|
16
16
|
All channels are peers — each one is a subscriber + caller against SessionManager.
|
|
17
17
|
|
|
18
|
+
## Shared inbound skeleton
|
|
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).
|
|
21
|
+
|
|
22
|
+
Three pieces:
|
|
23
|
+
|
|
24
|
+
- **`InboundBridge<Route>`** — per-account bookkeeping for session listeners + their reply routes (`setRoute` / `getRoute` / `ensureListener` / `dropListener` / `closeAll`). The **reply route** — where to send, plus any per-message credential like wechat's context token — is a per-session value refreshed on **every** inbound message, and responders read it lazily at send time via `bridge.getRoute(sessionId)`. A listener registered once therefore can't lock onto a stale destination. Route entries are dropped together with their listener, so the map can't outgrow the listener set (the old per-channel context-token map never got cleaned up) — but **only once the responder has drained**: `close()` may return a promise (slack / feishu serialize their per-chunk sends and hand one back; telegram / wechat reply in one message and return `void`), and the bridge defers the route deletion until it settles. Dropping the route synchronously stranded the tail of a split reply with nowhere to send (audit A-L3). `ensureListener` is idempotent per session; the account runner owns the bridge so `stopAccount` tears everything down with `closeAll()`, which walks the same per-listener teardown — deliberately no blanket `routes.clear()`, since that would delete the routes in-flight sends are about to read.
|
|
25
|
+
- **`deliverInbound`** — the tail itself: `rememberLastActiveChat` → access-level projection → resolve-or-create the channel session → `resolveGoalRoute` overlay → busy/compacting hint (hint only; delivery continues regardless) → `setRoute` → `ensureListener` → `appendUserMessage(uiText)` + `sendUserMessage("[channel: X | user: Y | thread: Z]\n" + agentText)`. Returns the (goal-routed) session id.
|
|
26
|
+
- **`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
|
+
|
|
28
|
+
Deliberately **not** shared (real semantic differences, not drift): message parsing, media download / ingest, slash-command gating (slack `!cmd` alias + DM-only, feishu p2p-only, telegram's `bot.command` registration, wechat `/qr`), the send primitives (thread routing, passive reply window), and post-command effects (workspace restart, wechat's `switchTo` listener migration).
|
|
29
|
+
|
|
18
30
|
## Data model
|
|
19
31
|
|
|
20
32
|
### Workspace ↔ Bot mapping
|
|
@@ -85,13 +97,10 @@ Routes: `packages/server/src/routes/telegram.ts`
|
|
|
85
97
|
`bot.on('message:text')`:
|
|
86
98
|
1. Check user against `allowedUsers` whitelist
|
|
87
99
|
2. Resolve workspace (check path exists on disk)
|
|
88
|
-
3.
|
|
89
|
-
4.
|
|
90
|
-
5. If session is busy → reply "🔄 queued"
|
|
91
|
-
6. Register a `TelegramResponder` event listener (once per session)
|
|
92
|
-
7. `sm.sendUserMessage(sessionId, agentInput)` with channel prefix
|
|
100
|
+
3. If the text is a registered command, `runCommand` (→ `dispatchChannelCommand`) and stop
|
|
101
|
+
4. Otherwise `handleUserMessage` → `deliverInbound` ([shared skeleton](#shared-inbound-skeleton)): session resolve/create → goal route → busy hint → route `{chatId}` + `TelegramResponder` listener → deliver
|
|
93
102
|
|
|
94
|
-
Photos / documents trigger the same flow with file
|
|
103
|
+
Photos / documents / voice / video notes trigger the same flow with the saved-file note in the text.
|
|
95
104
|
|
|
96
105
|
## Slash commands (native Telegram /commands)
|
|
97
106
|
|
|
@@ -118,7 +127,10 @@ Same strategy as WeChat:
|
|
|
118
127
|
|
|
119
128
|
## Media support
|
|
120
129
|
|
|
121
|
-
Inbound: text, photos (
|
|
130
|
+
Inbound: text, photos, documents, voice, video notes. All four media kinds go through the same `fetchAndSaveTelegramFile()` helper — download, persist under `<workspace>/.halo/assets/telegram/inbound/<accountId>/<date>/` via `saveInboundMedia`, and put the **local** path in the note the agent sees (`[文件 "x.pdf" 已保存: /abs/path]`). Two hard rules baked into that helper:
|
|
131
|
+
|
|
132
|
+
- **Never a getFile URL in the note.** `https://api.telegram.org/file/bot<TOKEN>/…` embeds the bot token, and the note text is persisted verbatim into the session log — UI log, LLM context and disk (audit A-H2). A failed download degrades to the bare filename, never the URL.
|
|
133
|
+
- **20 MB cap** (`MAX_TG_DOWNLOAD_BYTES`) checked against `file_size` up front, because the Bot API's `getFile` refuses anything larger with a generic error. Over the cap → the user gets a readable "file too big" reply and the message is dropped rather than delivered with a broken note.
|
|
122
134
|
|
|
123
135
|
Outbound: the `send-file` skill produces `MEDIA:<path>` markers. The responder sends files via grammy's `InputFile`:
|
|
124
136
|
- `.jpg/.png/.gif/.webp/.bmp` → `sendPhoto`
|
|
@@ -171,6 +171,7 @@ node server.js
|
|
|
171
171
|
### Security model
|
|
172
172
|
|
|
173
173
|
- **Proxy mode (default)**: `HALO_TOKEN` and `HALO_API` are server-side only, never exposed to the browser; frontend uses same-origin relative paths (`/chat`, `/history`, etc.); web-demo server proxies all requests to Halo, injecting the token in `x-token` header. Session auth: HMAC token in `x-session` header + localStorage. All proxy routes including `GET /file` are gated by the auth middleware.
|
|
174
|
+
- **Session TTL: 7 days**, enforced without server-side storage — the signed payload *is* the issue timestamp, so `isValidSession` re-parses it and rejects anything older than `SESSION_TTL_MS`. A leaked token therefore stops working on its own; the frontend gets a 401 and falls back to the login view (`handleAuthFail → logout`). Previously the signature alone was checked, making every issued token valid forever.
|
|
174
175
|
- **Direct-connect mode (opt-in)**: the gear panel takes a halo server URL + web-channel token; the browser then calls `<server>/api/web/*` directly with `x-token`, bypassing the proxy (no password step). The token is stored in that browser's localStorage by explicit user choice — the UI says so next to the field. One slot; clearing it returns to proxy mode. Works because the server CORS reflects any origin and allowlists `x-token`.
|
|
175
176
|
|
|
176
177
|
### Proxy routes
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# WeChat Channel — Design
|
|
2
2
|
|
|
3
3
|
Let the user talk to Halo from WeChat on their phone, sharing the same workspace + session as the web side.
|
|
4
4
|
|
|
@@ -13,7 +13,7 @@ Halo server (9527) ──┤── channels/telegram/ ├── Sess
|
|
|
13
13
|
ilinkai.weixin.qq.com
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
All channels are peers — each one is a subscriber + caller against SessionManager. Common slash commands (`/help`, `/evo`, and the object commands `/session`, `/agent`, `/skill`, `/workspace`) live in `channels/shared/commands.ts`; each channel handler is a thin adapter that builds a `CommandContext` and formats the result for its transport.
|
|
16
|
+
All channels are peers — each one is a subscriber + caller against SessionManager. Common slash commands (`/help`, `/evo`, and the object commands `/session`, `/agent`, `/skill`, `/workspace`) live in `channels/shared/commands.ts`; each channel handler is a thin adapter that builds a `CommandContext` and formats the result for its transport. The inbound tail (session resolve → goal route → busy hint → responder wiring → deliver) is likewise shared — see [Shared inbound skeleton](telegram.md#shared-inbound-skeleton).
|
|
17
17
|
|
|
18
18
|
## Data model
|
|
19
19
|
|
|
@@ -65,19 +65,19 @@ WeChat is single-recipient per dispatch (no fan-out across `allowedUsers` like t
|
|
|
65
65
|
|
|
66
66
|
Files: `packages/server/src/channels/wechat/`
|
|
67
67
|
|
|
68
|
-
- `api.ts` — HTTP client (getUpdates long-poll / sendMessage / notifyStart / notifyStop)
|
|
69
|
-
- `login.ts` — QR login (
|
|
68
|
+
- `api.ts` — HTTP client (getUpdates long-poll / sendMessage / notifyStart / notifyStop). `QR_BASE_URL` still points at `ilinkai.weixin.qq.com` — the upstream vendor host, not a naming leftover.
|
|
69
|
+
- `login.ts` — QR login (`startLogin` + `waitLogin`)
|
|
70
70
|
- `accounts.ts` — DAL (insertAccount / updateAccount / deleteAccount / list / normalize / saveSyncBuf)
|
|
71
71
|
- `cdn.ts` — media CDN client (download + decrypt)
|
|
72
|
-
- `media-store.ts` — saves inbound media to the workspace. Shared with the web chat channel: WeChat uses `<workspace>/.halo/assets/weixin/inbound/<accountId>/<date
|
|
72
|
+
- `media-store.ts` (in `channels/shared/`) — saves inbound media to the workspace. Shared with the web chat channel: WeChat uses `<workspace>/.halo/assets/weixin/inbound/<accountId>/<date>/` (the `weixin` path segment is kept for backward compatibility with already-saved assets — the code, routes and db all say `wechat`), web pasted images land in `<workspace>/.halo/assets/web/inbound/<date>/`. Both emit `[图片已保存: /abs/path]` markers that the UI turns into media chips.
|
|
73
73
|
- `send-media.ts` — uploads and sends files/images out
|
|
74
74
|
- `handler.ts` — long-poll main loop, routes messages to SessionManager + slash-command dispatch
|
|
75
75
|
- `event-adapter.ts` — LLM streaming events → WeChat whole-message send (coalesce by 200 chars / 3s silence / complete flush)
|
|
76
76
|
|
|
77
77
|
## SessionManager dependencies
|
|
78
78
|
|
|
79
|
-
- **Multi-subscriber listener**: `eventListeners: Map<rootId, Set<handler>>` — web and
|
|
80
|
-
- **Registry**: `SessionManagerRegistry` caches SessionManager instances by workspace path; web and
|
|
79
|
+
- **Multi-subscriber listener**: `eventListeners: Map<rootId, Set<handler>>` — web and wechat can subscribe to the same session simultaneously
|
|
80
|
+
- **Registry**: `SessionManagerRegistry` caches SessionManager instances by workspace path; web and wechat share it
|
|
81
81
|
|
|
82
82
|
## Long-poll main loop
|
|
83
83
|
|
|
@@ -94,17 +94,11 @@ loop {
|
|
|
94
94
|
|
|
95
95
|
> **单实例依赖**:长轮询循环跟 HTTP server 解耦 —— 即便 `:9527` 没绑上,只要进程活着,`runAccountLoop` 就继续拉消息。如果有孤儿 server 进程残留,每个进程都会独立收到同一条消息并 fan out 到不同 session(每个进程内存里的 `activeOverrides` 是独立的,`findActiveSessionId` 可能返回不同结果)。因此 `src/index.ts` 在启动时要抢 `~/.halo/global/server.pid` 单实例锁,详见 [dev/deploy.md](../dev/deploy.md#4-start-the-server)。
|
|
96
96
|
|
|
97
|
-
`handleInbound(account, msg)
|
|
97
|
+
`handleInbound(account, msg)` — WeChat-specific head, then the shared tail:
|
|
98
98
|
1. Extract `from_user_id` / text + media items / `context_token`
|
|
99
99
|
2. Process inbound media: images → base64 attached to the agent message; voice / video / files → saved under the workspace with a `[voice saved: ...]` etc. marker appended to the text
|
|
100
100
|
3. If the text begins with `/`, dispatch a slash command (see below) — if it returns `handled`, stop here
|
|
101
|
-
4.
|
|
102
|
-
5. Resolve the active session for this user (override from `/session new`/`/session switch`; otherwise the most recent non-archived session matching the user prefix)
|
|
103
|
-
6. If no active session exists, create one with the inherited access level
|
|
104
|
-
7. If the session is compacting, reply with `⏳ integrating context, try again in ~30s` and skip queueing
|
|
105
|
-
8. If the session is busy, reply with `🔄 still processing last message, queued` and still enqueue
|
|
106
|
-
9. Register a WeixinResponder event listener (once per session) that coalesces outbound text
|
|
107
|
-
10. `sm.sendUserMessage(sessionId, agentInput, images?)` — the actual agent input is prefixed with `[channel: wechat | user: <id>]` so the agent knows how to address the media-send skill
|
|
101
|
+
4. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)), which caches `lastActiveChatId`, resolves-or-creates the session, applies the goal-mode route overlay, 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)
|
|
108
102
|
|
|
109
103
|
## Slash commands
|
|
110
104
|
|
|
@@ -121,7 +115,7 @@ Implemented in `handler.ts`'s `handleSlashCommand()`:
|
|
|
121
115
|
|
|
122
116
|
Unknown `/` input falls through to normal message handling.
|
|
123
117
|
|
|
124
|
-
## Event coalescing (
|
|
118
|
+
## Event coalescing (WechatResponder)
|
|
125
119
|
|
|
126
120
|
WeChat `sendMessage` is block-send, while LLMs stream. The current strategy is "buffer the whole turn, flush as one message":
|
|
127
121
|
- `stream` events accumulate in a single buffer
|
|
@@ -131,6 +125,8 @@ WeChat `sendMessage` is block-send, while LLMs stream. The current strategy is "
|
|
|
131
125
|
- Tool calls / tool results / thinking are dropped (detail lives in the web UI)
|
|
132
126
|
- Media: the agent emits `MEDIA: <path>` markers, which the responder extracts and turns into actual media uploads
|
|
133
127
|
|
|
128
|
+
**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.
|
|
129
|
+
|
|
134
130
|
## Supported inbound media
|
|
135
131
|
|
|
136
132
|
Plain text + images (base64 attached to the agent message), voice, video, files. For voice/video/files, the content is decrypted (AES from CDN), saved under the workspace, and referenced in the agent's input text.
|
|
@@ -143,4 +139,4 @@ Not supported: group chats, typing indicators.
|
|
|
143
139
|
|
|
144
140
|
## References
|
|
145
141
|
|
|
146
|
-
Original rollout plan: [plans/weixin-channel.md](../plans/weixin-channel.md).
|
|
142
|
+
Original rollout plan: [plans/weixin-channel.md](../plans/weixin-channel.md) (local-only; written before the module was renamed `wechat`, so it still says "weixin" throughout).
|
|
@@ -62,13 +62,13 @@ Source: [handler.ts](../../../packages/server/src/ws/handler.ts) — top-level `
|
|
|
62
62
|
| `__ping__` | Application-level liveness probe; server replies `__pong__` |
|
|
63
63
|
| `chat:stop` | Hard-abort the current generation (ends the turn, no re-run) → `stopUserSession` |
|
|
64
64
|
| `chat:interrupt` | Interrupt the in-flight turn now (aborts a command mid-run); the server then folds any queued messages into one follow-up turn → `interruptSession`. Admin chat esc maps to this. A compacting session cancels the compact instead (same as `chat:stop`). |
|
|
65
|
-
| `session:clear` | Non-destructive /session new: save the current,
|
|
65
|
+
| `session:clear` | Non-destructive /session new: save the current, release its listener, create fresh (handled inline) — see [Command dispatch](#command-dispatch) |
|
|
66
66
|
| `session:delete` | Delete session files + cascade-delete descendants in SQLite (handled inline) |
|
|
67
|
-
| `exchange:delete` | Delete one exchange (a user turn + its responses): **soft-delete** in the UI log (`deleted: true` markers, kept visible/greyed) + **physical-delete** the whole turn from `rawMessages` (LLM context) → `deleteExchange`. Fields: `userOrdinal` (0-based index among *main-conversation* user turns, i.e. excluding `taskId` sub-agent messages — matches the admin's `isMainConversationMessage` count), optional `sessionId` / `projectId`. Rejected (→ `error`) while the session is running or compacting. See [session.md](session.md#exchange-deletion-soft-ui--hard-raw). |
|
|
67
|
+
| `exchange:delete` | Delete one exchange (a user turn + its responses): **soft-delete** in the UI log (`deleted: true` markers, kept visible/greyed) + **physical-delete** the whole turn from `rawMessages` (LLM context) → `deleteExchange`. Fields: `userOrdinal` (0-based index among *main-conversation* user turns, i.e. excluding `taskId` sub-agent messages — matches the admin's `isMainConversationMessage` count), optional `sessionId` / `projectId`. Rejected (→ `error`) while the session is running or compacting, and permanently once the session has archived UI-log segments — that refusal carries `code: 'archived'` (an ordinal is only meaningful while both sides agree where the log starts; archiving moves the start). See [session.md](session.md#exchange-deletion-soft-ui--hard-raw). |
|
|
68
68
|
| `command:<name>` | Route through shared `dispatchCommand` (see [command.md](command.md)); `/session compact` handled inline for UI callbacks |
|
|
69
69
|
| `terminal:start` | Spawn a new PTY |
|
|
70
|
-
| `terminal:input` | Send keystrokes |
|
|
71
|
-
| `terminal:resize` | Resize terminal |
|
|
70
|
+
| `terminal:input` | Send keystrokes. `terminalId` is **required** — an id-less frame is logged and dropped (it used to fall back to the first entry of the process-global terminal map, which with several admin connections open could write into another browser's PTY) |
|
|
71
|
+
| `terminal:resize` | Resize terminal. `terminalId` required, same rejection as `terminal:input` |
|
|
72
72
|
| `terminal:close` | Kill the PTY |
|
|
73
73
|
| `terminal:reattach` | Re-attach every detached terminal after reconnect |
|
|
74
74
|
|
|
@@ -105,17 +105,17 @@ Source: [event-processor.ts:48-97](../../../packages/server/src/ws/event-process
|
|
|
105
105
|
|
|
106
106
|
| Type | Source | Purpose |
|
|
107
107
|
|---|---|---|
|
|
108
|
-
| `state:snapshot` | handler.ts on connect | Initial state (agents, messages, sessionId) |
|
|
108
|
+
| `state:snapshot` | handler.ts on connect | Initial state (agents, messages, sessionId). On **subscribe / reattach only** it also carries `archiveCount` — the number of committed UI-log archive segments, which the admin's scroll-up loader counts down from (see [Archive anchor](#archive-anchor-in-statesnapshot)) |
|
|
109
109
|
| `chat:ack` | `handleChat` | Chat with `clientMsgId` is persisted in the session log — releases the client's pending-ack entry (see [Chat delivery](#chat-delivery-ack--resend--dedup)) |
|
|
110
110
|
| `__pong__` | `__ping__` handler | Reply to the client's application-level liveness probe |
|
|
111
111
|
| `listener:released` | `reclaimIfAbandoned` (this client only) | This connection's event listener was reclaimed (silent >3 min / CLOSED) — `{sessionId}`. Client must re-`subscribe` to reattach. See [Abandoned-listener reclaim](#abandoned-listener-reclaim-and-the-__ping__-contract). |
|
|
112
112
|
| `chat:queued` | `sendUserMessage` returning queued | User-message-queued notification |
|
|
113
|
-
| `file:changed` | WorkspaceWatcher · GitDirWatcher · `routes/git.ts` | File change notification (path + action). Three sources: (1) **WorkspaceWatcher** — recursive workspace watch, deliberately excludes `.git`; (2) **`routes/git.ts`** — every git mutation route re-broadcasts `path:'.git'` itself (the recursive watcher ignores `.git`); (3) **GitDirWatcher** — a non-recursive `.git`-dir watch for command-line git ops, *plus* a degraded "watch the workspace root for `.git` appearing" phase that fires `path:'.git'` on a terminal `git init`/`clone` so the Source Control entry auto-surfaces. See [source-control.md](../requirements/source-control.md#auto-refresh-no-polling). |
|
|
113
|
+
| `file:changed` | WorkspaceWatcher · GitDirWatcher · `routes/git.ts` | File change notification (path + action). Three sources: (1) **WorkspaceWatcher** — recursive workspace watch, deliberately excludes `.git`; (2) **`routes/git.ts`** — every git mutation route re-broadcasts `path:'.git'` itself (the recursive watcher ignores `.git`), via `broadcastToWorkspace` so only clients bound to that workspace are woken (a git write in A used to make every tab showing B refetch status + ignored + log); (3) **GitDirWatcher** — a non-recursive `.git`-dir watch for command-line git ops, *plus* a degraded "watch the workspace root for `.git` appearing" phase that fires `path:'.git'` on a terminal `git init`/`clone` so the Source Control entry auto-surfaces. See [source-control.md](../requirements/source-control.md#auto-refresh-no-polling). |
|
|
114
114
|
| `terminal:ready` / `terminal:output` / `terminal:exit` / `terminal:reattached` | TerminalManager | PTY output |
|
|
115
115
|
| `session:changed` | `SessionManager` (broadcast to all clients) | Root session list changed — re-fetch. Fires on root-session create *and* on each root turn `complete` (so channel-driven messages refresh the count/title/ordering, not just admin's own turns). |
|
|
116
116
|
| `session:switched` | handler.ts (this client only) | The server rebound this connection to a different session — see [switchTo rebind](#switchto-rebind--sessionswitched) |
|
|
117
117
|
| `goal:changed` | `writeGoalState` (`agents/goal-mode.ts`, broadcast to all clients) | Goal-mode state transition: `{goalSessionId, workerSessionId, status, round, maxRounds}`. Emitted on every goal state write (every transition routes through `writeGoalState`, so the push can never be forgotten) plus the goal-session-delete dissolve path. **No workspace marker** — the admin re-fetches through `GET /api/sessions/goal` under its active project, which naturally filters cross-workspace events. Drives the goal banner, worker input lock, and 🎯 badge refresh. See [goal-mode.md](goal-mode.md#admin-surface--ws). |
|
|
118
|
-
| `session:cleared` | session:clear handler | /session new complete |
|
|
118
|
+
| `session:cleared` | session:clear handler (this client only) | /session new complete — sent **after** `saveSession`, so the admin bumps its session-list bus on this event instead of guessing with a `setTimeout` |
|
|
119
119
|
| `session:deleted` | session:delete handler (this client only) | Session delete complete — `{sessionId}` |
|
|
120
120
|
| `chat:stopped` | `chat:stop` / `chat:interrupt` handlers (this client only) | Stop/interrupt acknowledged — `{sessionId}` |
|
|
121
121
|
| `session:compacted` | compact handler | Compaction complete |
|
|
@@ -123,6 +123,14 @@ Source: [event-processor.ts:48-97](../../../packages/server/src/ws/event-process
|
|
|
123
123
|
| `cron:job_changed` / `cron:run_changed` | `cron/runner.ts` + `routes/cron.ts` (broadcast) | Cron job/run state changed — the Cron tab re-fetches instead of polling. See [cron.md](cron.md). |
|
|
124
124
|
| `evolution:run_changed` / `evolution:apply_changed` | `evolution/ticker.ts` + `routes/evolution.ts` (broadcast) | Evolution run/apply state changed — drives the Evolution tab. See [evolution.md](evolution.md). |
|
|
125
125
|
|
|
126
|
+
### `error` frames: optional `code`
|
|
127
|
+
|
|
128
|
+
`{ type: 'error', error }` normally means "something failed"; the admin prefixes it with `Error:` when rendering. An **expected refusal** the server already phrased for the user adds a `code` (currently only `'archived'`, from `exchange:delete`), and the admin renders such a frame verbatim without the prefix. So: add `code` when the message is a complete user-facing sentence, omit it when the client should present it as a fault.
|
|
129
|
+
|
|
130
|
+
### Archive anchor in `state:snapshot`
|
|
131
|
+
|
|
132
|
+
`archiveCount` rides only the snapshots sent on **subscribe** and **reattach** — one header read per session open. The per-turn snapshots and the snapshot pushed after `exchange:delete` deliberately omit it: the client's "load older" cursor is an anchor pinned at the moment the session was opened, and a segment written mid-session (a compact while the tab is open) must not move it. The client then requests segments over HTTP (`GET /api/sessions/logs/:id/archive/:n`), not WS — one whole segment per call, cached client-side because segments are immutable. See [session.md](session.md#ui-log-archiving).
|
|
133
|
+
|
|
126
134
|
## WS Handler as a thin session client
|
|
127
135
|
|
|
128
136
|
> **History**: originally the WS handler directly created and owned Orchestrator instances in a `client.orchestrators` map. Agent logic is now fully owned by SessionManager; the WS handler is a thin routing client.
|
|
@@ -142,6 +150,7 @@ interface ConnectedClient {
|
|
|
142
150
|
fileWatcher: WorkspaceWatcher
|
|
143
151
|
gitDirWatcher: GitDirWatcher
|
|
144
152
|
lastClientPingAt: number // wall-clock ms of last INBOUND frame — the reclaim's liveness stamp
|
|
153
|
+
commandUserId: string // `ws-<n>`, this connection's key into the command layer's active-session map
|
|
145
154
|
}
|
|
146
155
|
```
|
|
147
156
|
|
|
@@ -149,10 +158,19 @@ UI state (messageLog / streamBuffer / turnToolCalls / tokens) belongs to Session
|
|
|
149
158
|
|
|
150
159
|
### Command dispatch
|
|
151
160
|
|
|
152
|
-
- `session:clear` / `session:delete` — handled inline (save/detach/delete logic specific to WS client lifecycle)
|
|
161
|
+
- `session:clear` / `session:delete` — handled inline (save/detach/delete logic specific to WS client lifecycle). `session:clear` saves, then **releases the event listener and registers nothing in its place**: a cleared session is deliberately abandoned (the admin wipes its chat store on `session:cleared`), and SessionUIStore keeps folding + persisting a still-running session's events with zero listeners, so a later re-open subscribes fresh and gets the full snapshot. The buffering `bgHandler` this used to register — whose `unsubscribe` was discarded and whose `pendingEvents` were never drained — leaked one listener per "New session" click.
|
|
153
162
|
- `command:session` with `compact` verb — calls `sm.compactSession(sid, { onProgress })` directly for real-time progress feedback
|
|
154
163
|
- All other `command:*` — builds a shared `CommandContext` and routes through `dispatchCommand()` (see [command.md](command.md))
|
|
155
164
|
|
|
165
|
+
**Active-session override is per connection.** The shared command layer tracks
|
|
166
|
+
"which session is this user on" in an `activeOverrides` map; the WS handler owns
|
|
167
|
+
one map at handler scope but keys it by `client.commandUserId` (`ws-1`, `ws-2`,
|
|
168
|
+
… minted per socket) rather than a single literal `'ws'`. With a global key, two
|
|
169
|
+
admin tabs on different sessions overwrote each other's current session and
|
|
170
|
+
slash commands landed in the wrong conversation. The entry is deleted on close,
|
|
171
|
+
so the map stays the size of the open connection set. Subscribe / detach /
|
|
172
|
+
reattach lifecycle is unchanged — this only affects command routing.
|
|
173
|
+
|
|
156
174
|
### switchTo rebind & `session:switched`
|
|
157
175
|
|
|
158
176
|
When a command result carries `switchTo` (e.g. `/new`, `/goal create`, `/goal resume`), the WS handler doesn't just report the new id — it **rebinds this client's event stream**: unsubscribe the old listener, set `client.sessionId`, `registerEventListener` on the target, then send `session:switched {sessionId}`. Without the rebind, streaming events from the switched-to session (e.g. the goal agent's intake greeting right after `/goal create`) would never reach the connection — the listener would still point at the old id.
|
|
@@ -176,12 +194,14 @@ When the client drops while an agent is still working:
|
|
|
176
194
|
6. Event handler: `bufferDetachedNotification(event, pendingEvents)` — buffers structural events only (agent_start / agent_done / error / system / followup / complete)
|
|
177
195
|
7. On grace expiry: session is saved and torn down
|
|
178
196
|
|
|
197
|
+
Teardown lives in one function (`cleanupConnection`) shared by the socket's `close` **and** `error` events: ws normally emits `close` right after `error`, but nothing guarantees it, and the old error path only stopped the watchers — leaking the keepalive interval, the event listener, unflushed background saves and attached PTYs. `clients.delete(client)` is the idempotency gate, so the usual error→close double-fire runs the body exactly once (a second detach pass would overwrite the `detachedSessions` entry and double-register its buffering handler).
|
|
198
|
+
|
|
179
199
|
### Reconnect flow
|
|
180
200
|
|
|
181
201
|
Client reconnects and sends `subscribe`:
|
|
182
202
|
1. Server detects the detached session
|
|
183
203
|
2. Loads UIState from SessionManager (via `getUIState`) and builds a save snapshot
|
|
184
|
-
3. Sends `state:snapshot` — messages from `createSaveSnapshot(state)`, or the settled `messageLog` only when the session is still running (see step 6)
|
|
204
|
+
3. Sends `state:snapshot` — messages from `createSaveSnapshot(state)`, or the settled `messageLog` only when the session is still running (see step 6) — plus `archiveCount`, re-pinning the scroll-up anchor for the reopened log
|
|
185
205
|
4. Replays buffered `pendingEvents`
|
|
186
206
|
5. Re-attach the event listener — live streaming continues seamlessly
|
|
187
207
|
6. If the session is still running, synthesizes the in-progress turn as an **authoritative replay**: every synthesized message carries `replay: true` — a `chat:followup`, then the turn's `turnContentBlocks` in order (`chat:thinking` / `chat:stream` / `agent:tool_call` + `agent:tool_result` if completed), each with the session's real `agentName` and the block's real `turnId` so usage badges / turn grouping survive the reconnect. The running-session snapshot in step 3 sends the settled `messageLog` only (no `createSaveSnapshot` temp in-flight message — the replay carries that turn instead).
|
|
@@ -192,6 +212,12 @@ Client reconnects and sends `subscribe`:
|
|
|
192
212
|
|
|
193
213
|
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.
|
|
194
214
|
|
|
215
|
+
### Client-side reconnect reconciliation
|
|
216
|
+
|
|
217
|
+
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`).
|
|
218
|
+
|
|
219
|
+
`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).
|
|
220
|
+
|
|
195
221
|
## Background session dispatch
|
|
196
222
|
|
|
197
223
|
When the user hits `/session new` while a sub-agent is still running: see [background-dispatch.md](background-dispatch.md).
|
|
@@ -55,13 +55,11 @@ Every channel, on every inbound message, runs this sequence:
|
|
|
55
55
|
|
|
56
56
|
1. **Receive.** Webhook endpoint or long-poll loop gets a raw platform message.
|
|
57
57
|
2. **Extract.** Pull user ID, workspace binding, text, media. Platform-specific — this is why each channel has its own `types.ts`.
|
|
58
|
-
3. **Resolve workspace.** Look up the bound workspace for this account / user. For
|
|
58
|
+
3. **Resolve workspace.** Look up the bound workspace for this account / user. For WeChat this is a DB column; for Slack you'd use team → workspace mapping.
|
|
59
59
|
4. **Get a SessionManager.** `registry.getOrCreate(workspacePath)` — cheap, memoized per workspace.
|
|
60
|
-
5. **
|
|
61
|
-
6. **Register an event listener once per session.** `sm.registerEventListener(sessionId, handler)` — handler receives every agent event. Coalesce streamed text into whole outbound messages (platforms hate per-token streams).
|
|
62
|
-
7. **Send the user message.** `sm.sendUserMessage(sessionId, text, images?)`. This returns immediately; the agent runs async and emits events to your listener.
|
|
60
|
+
5. **Hand off to `deliverInbound`.** Everything downstream is shared ([channels/shared/inbound.ts](../../../packages/server/src/channels/shared/inbound.ts)): resolve-or-create the session under a per-user `activeOverrides` override (`/session new` / `/session switch` update it), fold the account access level, send the busy hint, refresh the reply **route**, register the responder listener once per session (`sm.registerEventListener`), then `appendUserMessage` + `sendUserMessage`. Your channel supplies the route and the send primitives; the responder coalesces streamed text into whole outbound messages (platforms hate per-token streams).
|
|
63
61
|
|
|
64
|
-
|
|
62
|
+
Steps 1-4 are yours, step 5 is shared — see [step 4 below](#4-write-the-main-handler) for the call shape. Canonical example: [packages/server/src/channels/wechat/handler.ts](../../../packages/server/src/channels/wechat/handler.ts) (`handleInbound`).
|
|
65
63
|
|
|
66
64
|
---
|
|
67
65
|
|
|
@@ -78,7 +76,7 @@ packages/server/src/channels/slack/
|
|
|
78
76
|
└── event-adapter.ts
|
|
79
77
|
```
|
|
80
78
|
|
|
81
|
-
Copy the
|
|
79
|
+
Copy the WeChat shapes to get started, then rewrite against the Slack API.
|
|
82
80
|
|
|
83
81
|
### 2. Define the account adapter
|
|
84
82
|
|
|
@@ -103,35 +101,54 @@ Inbound: Slack pushes events via **HTTPS webhooks** (Events API) or **WebSocket*
|
|
|
103
101
|
|
|
104
102
|
### 4. Write the main handler
|
|
105
103
|
|
|
106
|
-
`handler.ts`: exports `startSlackChannel(deps: { registry, db })` returning `{ startAccount, stopAccount, stopAll }`.
|
|
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).
|
|
107
105
|
|
|
108
106
|
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).
|
|
109
107
|
|
|
110
|
-
Inside `handleInbound
|
|
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**:
|
|
111
109
|
|
|
112
110
|
```ts
|
|
113
|
-
|
|
111
|
+
// One bridge per running account, owned by the account state so
|
|
112
|
+
// stopAccount can closeAll(). The responder reads the route LAZILY at
|
|
113
|
+
// send time — never capture the chat/user in the closure (A-M2).
|
|
114
|
+
const bridge: InboundBridge<SlackRoute> = new InboundBridge({
|
|
115
|
+
channel: 'slack',
|
|
116
|
+
makeResponder: (sessionId) => new SlackResponder({
|
|
117
|
+
sendText: async (chunk) => {
|
|
118
|
+
const route = bridge.getRoute(sessionId)
|
|
119
|
+
if (!route) return
|
|
120
|
+
await postMessage({ botToken, channel: route.channelId, threadTs: route.replyTs, text: chunk })
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
async function handleInbound({ registry, db, account, event, bridge, activeOverrides }) {
|
|
114
126
|
const sm = registry.getOrCreate(account.workspacePath)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
await deliverInbound({
|
|
128
|
+
sm, db, accountId: account.accountId, bridge,
|
|
129
|
+
chatKey: event.channel, // cached for cron delivery
|
|
130
|
+
tagKey: `${event.user}:${event.threadTs}`, // activeOverrides key
|
|
131
|
+
sessionPrefix: `slack-${account.accountId}-`,
|
|
132
|
+
activeOverrides,
|
|
133
|
+
accountAccessLevel: account.accessLevel,
|
|
134
|
+
workspacePath: account.workspacePath,
|
|
135
|
+
sessionLabel: `Slack: ${event.user}`,
|
|
136
|
+
setOverrideOnCreate: true, // per-thread channels pin it
|
|
137
|
+
route: { channelId: event.channel, replyTs: event.threadTs }, // THIS message
|
|
138
|
+
lang: account.language, sendHint: (t) => postEphemeral(t),
|
|
139
|
+
uiText: event.text, agentText: event.text, userTag: event.user,
|
|
140
|
+
images: event.images,
|
|
141
|
+
})
|
|
129
142
|
}
|
|
130
143
|
```
|
|
131
144
|
|
|
145
|
+
`deliverInbound` refreshes the route on every message before `ensureListener`, so a listener registered once follows the conversation to whoever spoke last. Slash commands go through `dispatchChannelCommand` (same `bridge` + `route`), which wires the responder when the command reports `startedTurn`.
|
|
146
|
+
|
|
132
147
|
### 5. Write the event adapter
|
|
133
148
|
|
|
134
|
-
`event-adapter.ts`: buffers streamed text and sends whole messages.
|
|
149
|
+
`event-adapter.ts`: buffers streamed text and sends whole messages. WeChat's flushes on either a 3500-char hard ceiling (platform limit) or a `complete` event; see [packages/server/src/channels/wechat/event-adapter.ts](../../../packages/server/src/channels/wechat/event-adapter.ts). Slack has a 40k char limit but users dislike huge messages — consider splitting at paragraph boundaries.
|
|
150
|
+
|
|
151
|
+
Its send primitives take **no destination argument** — they read `bridge.getRoute(sessionId)` on each send (see step 4). A responder that captured a chat/user id at construction time is the A-M2 bug.
|
|
135
152
|
|
|
136
153
|
**What to forward, what to drop**:
|
|
137
154
|
- Drop `tool_call` / `tool_result` / `thinking` events — they're chatter the user doesn't want in IM
|
|
@@ -218,7 +235,7 @@ export function createSlackRoutes(deps: { db, channel }) {
|
|
|
218
235
|
}
|
|
219
236
|
```
|
|
220
237
|
|
|
221
|
-
Pattern: [packages/server/src/routes/
|
|
238
|
+
Pattern: [packages/server/src/routes/wechat.ts](../../../packages/server/src/routes/wechat.ts).
|
|
222
239
|
|
|
223
240
|
### 8. Write the server descriptor + register
|
|
224
241
|
|
|
@@ -258,7 +275,7 @@ That's all the server wiring. `index.ts` calls `bootChannels(app, defaultChannel
|
|
|
258
275
|
|
|
259
276
|
To let users manage Slack accounts from the web UI:
|
|
260
277
|
|
|
261
|
-
1. Build the React component (mirror [packages/admin/src/features/
|
|
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`.
|
|
262
279
|
2. Create `packages/admin/src/features/slack/descriptor.ts`:
|
|
263
280
|
```ts
|
|
264
281
|
import { Hash } from 'lucide-react' // or whatever icon fits
|
|
@@ -277,7 +294,7 @@ To let users manage Slack accounts from the web UI:
|
|
|
277
294
|
import { slackAdminDescriptor } from '@/features/slack/descriptor'
|
|
278
295
|
|
|
279
296
|
export const defaultAdminChannelDescriptors = [
|
|
280
|
-
|
|
297
|
+
wechatAdminDescriptor,
|
|
281
298
|
telegramAdminDescriptor,
|
|
282
299
|
webAdminDescriptor,
|
|
283
300
|
slackAdminDescriptor, // ← one new line
|
|
@@ -328,13 +345,7 @@ Channel-specific commands (e.g. WeChat's `/qr`) go in a fallback switch after `d
|
|
|
328
345
|
|
|
329
346
|
### Compact / busy states
|
|
330
347
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
```ts
|
|
334
|
-
if (sm.isSessionCompacting(sid)) { /* reply + return, don't enqueue */ }
|
|
335
|
-
if (sm.isSessionRunning(sid)) { /* reply + enqueue */ }
|
|
336
|
-
sm.sendUserMessage(sid, text, images)
|
|
337
|
-
```
|
|
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.)
|
|
338
349
|
|
|
339
350
|
### Single-instance lock
|
|
340
351
|
|
|
@@ -348,7 +359,7 @@ At handler startup, `resolveAccountWorkspace(account)` checks the path exists on
|
|
|
348
359
|
|
|
349
360
|
### Media
|
|
350
361
|
|
|
351
|
-
Use the shared helper `saveInboundMedia({ workspacePath, accountId, channel: 'slack', buffer, kind: 'image', mimeType })` — [packages/server/src/channels/
|
|
362
|
+
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.
|
|
352
363
|
|
|
353
364
|
For images going to the LLM, also pass them as base64 in the `images` arg — `sm.sendUserMessage(sid, text, images)`.
|
|
354
365
|
|
|
@@ -369,8 +380,9 @@ No automated test framework for channels — manual for now:
|
|
|
369
380
|
|
|
370
381
|
## References
|
|
371
382
|
|
|
372
|
-
-
|
|
373
|
-
-
|
|
383
|
+
- Shared inbound skeleton: [packages/server/src/channels/shared/inbound.ts](../../../packages/server/src/channels/shared/inbound.ts) (also written up in [design/telegram.md](../design/telegram.md#shared-inbound-skeleton))
|
|
384
|
+
- Full WeChat reference: [design/wechat.md](../design/wechat.md)
|
|
385
|
+
- WeChat code: [packages/server/src/channels/wechat/](../../../packages/server/src/channels/wechat/)
|
|
374
386
|
- Architecture seat: [design/architecture.md](../design/architecture.md)
|
|
375
387
|
- Session registry: [packages/server/src/agents/session-manager-registry.ts](../../../packages/server/src/agents/session-manager-registry.ts)
|
|
376
388
|
- Storage conventions: [design/storage.md](../design/storage.md)
|