@turmind/halo 1.5.3-alpha → 1.5.5

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 (75) 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-bc743317498d3fbc.js +1 -0
  4. package/admin-out/_next/static/chunks/app/page-0a390059f1914664.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/b53542687ebd4c65.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 +4 -4
  10. package/bundled-docs/design/agentcore.md +13 -10
  11. package/bundled-docs/design/architecture.md +24 -14
  12. package/bundled-docs/design/command.md +5 -5
  13. package/bundled-docs/design/cron.md +2 -2
  14. package/bundled-docs/design/evolution.md +34 -35
  15. package/bundled-docs/design/express-self.md +6 -4
  16. package/bundled-docs/design/feishu.md +18 -14
  17. package/bundled-docs/design/halo-city.md +5 -4
  18. package/bundled-docs/design/observability.md +9 -9
  19. package/bundled-docs/design/prompt-system.md +12 -12
  20. package/bundled-docs/design/session.md +12 -12
  21. package/bundled-docs/design/slack.md +22 -20
  22. package/bundled-docs/design/storage.md +18 -19
  23. package/bundled-docs/design/telegram.md +26 -8
  24. package/bundled-docs/design/web.md +3 -1
  25. package/bundled-docs/design/wechat.md +8 -6
  26. package/bundled-docs/design/wecom.md +23 -9
  27. package/bundled-docs/design/ws.md +6 -6
  28. package/bundled-docs/dev/acp-adapter.md +8 -8
  29. package/bundled-docs/dev/add-channel.md +12 -9
  30. package/bundled-docs/dev/add-model-provider.md +31 -20
  31. package/bundled-docs/dev/api.md +83 -57
  32. package/bundled-docs/dev/deploy.md +8 -6
  33. package/bundled-docs/dev/env.md +16 -11
  34. package/bundled-docs/dev/logging.md +15 -29
  35. package/bundled-docs/dev/previews.md +20 -11
  36. package/bundled-docs/dev/tools.md +24 -12
  37. package/bundled-docs/guide/README.md +1 -0
  38. package/bundled-docs/guide/agents.md +19 -53
  39. package/bundled-docs/guide/channels/README.md +7 -4
  40. package/bundled-docs/guide/channels/acp.md +3 -1
  41. package/bundled-docs/guide/channels/feishu.md +13 -11
  42. package/bundled-docs/guide/channels/slack.md +12 -10
  43. package/bundled-docs/guide/channels/telegram.md +21 -27
  44. package/bundled-docs/guide/channels/web.md +15 -11
  45. package/bundled-docs/guide/channels/wechat.md +15 -14
  46. package/bundled-docs/guide/channels/wecom.md +7 -2
  47. package/bundled-docs/guide/chat.md +11 -10
  48. package/bundled-docs/guide/cli.md +10 -8
  49. package/bundled-docs/guide/getting-started.md +3 -3
  50. package/bundled-docs/guide/secrets-and-credentials.md +3 -3
  51. package/bundled-docs/guide/sessions.md +3 -3
  52. package/bundled-docs/guide/skills.md +8 -6
  53. package/bundled-docs/guide/testing-agents-and-skills.md +14 -15
  54. package/bundled-docs/guide/workspace.md +27 -15
  55. package/bundled-docs/requirements/agent.md +19 -18
  56. package/bundled-docs/requirements/chat.md +19 -21
  57. package/bundled-docs/requirements/command.md +10 -10
  58. package/bundled-docs/requirements/editor.md +10 -9
  59. package/bundled-docs/requirements/explorer.md +10 -7
  60. package/bundled-docs/requirements/session.md +16 -17
  61. package/bundled-docs/requirements/settings.md +18 -14
  62. package/bundled-docs/requirements/skill.md +4 -4
  63. package/bundled-docs/requirements/source-control.md +4 -3
  64. package/bundled-docs/requirements/terminal.md +13 -13
  65. package/bundled-docs/requirements/workspace.md +19 -13
  66. package/dist/index.js +2038 -3558
  67. package/package.json +1 -1
  68. package/templates/config.yaml +6 -0
  69. package/templates/skills/workspace/share.md +11 -3
  70. package/templates/skills/workspace/stage.py +85 -5
  71. package/admin-out/_next/static/chunks/989-c6c7772db8a50926.js +0 -1
  72. package/admin-out/_next/static/chunks/app/page-5f41b086cd5ad353.js +0 -1
  73. package/admin-out/_next/static/css/8809d33379458514.css +0 -3
  74. /package/admin-out/_next/static/{Wqi_ugna9MYsiV7ECuawQ → cuVcldfdg1PXevaQMQSzb}/_buildManifest.js +0 -0
  75. /package/admin-out/_next/static/{Wqi_ugna9MYsiV7ECuawQ → cuVcldfdg1PXevaQMQSzb}/_ssgManifest.js +0 -0
@@ -17,14 +17,20 @@
17
17
  │ Route mounts (direct): │
18
18
  │ /api/auth/* → createAuthRoutes() │
19
19
  │ /api/files/* → createFileRoutes() │
20
+ │ /api/data-preview/*→ createDataPreviewRoutes() │
21
+ │ /api/git/* → createGitRoutes() │
20
22
  │ /api/fs/* → createFileRoutes (home/exists/browse/resolve)│
21
23
  │ /api/agent-configs/*→ createAgentConfigRoutes() │
22
24
  │ /api/skills/* → createSkillRoutes() │
23
25
  │ /api/settings/* → createSettingsRoutes() │
24
26
  │ /api/evolution/* → createEvolutionRoutes() │
25
27
  │ /api/cron/* → createCronRoutes() │
28
+ │ /api/extensions/* → createExtensionRoutes() │
26
29
  │ /api/sessions/* → createSessionRoutes() │
30
+ │ /api/sessions/logs/:id/archive/:n → createSessionArchiveRoutes() │
27
31
  │ /api/show/* → createShowRoutes() (halo-city snapshot) │
32
+ │ /api/metrics → createMetricsRoutes() │
33
+ │ /api/health (inline in index.ts) │
28
34
  │ /api/commands → createCommandRoutes() │
29
35
  │ Route mounts (via bootChannels — registry-driven): │
30
36
  │ /api/web/* (web descriptor) │
@@ -32,8 +38,10 @@
32
38
  │ /api/wechat/* (wechat descriptor) │
33
39
  │ /api/slack/* (slack descriptor) │
34
40
  │ /api/feishu/* (feishu descriptor) │
41
+ │ /api/wecom/* (wecom descriptor) │
35
42
  │ /ws → setupWebSocketHandler({wss,registry}) │
36
43
  │ /* → static frontend (Next.js out/) │
44
+ │ /ping, /invocations → createAgentCoreRoutes() (agentcore mode) │
37
45
  └─────────────────────────────────────────────────────────────────────┘
38
46
  ```
39
47
 
@@ -74,7 +82,7 @@ createModelRuntime(providerId: string, cfg: ModelRuntimeConfig): ModelRuntime
74
82
  **Current providers**:
75
83
  - `aws-bedrock-claude-invoke` → `BedrockAgent` (uses the Bedrock InvokeModelWithResponseStream API — text / thinking chunks reach the UI as they are generated)
76
84
  - `anthropic` / `mimo-token-plan-china` → `AnthropicAgent`, `minimax` → `MiniMaxAgent`, `qwen` → `QwenAgent` (Anthropic Messages API over HTTP with `stream: true`; the SSE is folded by the same `AnthropicStreamAccumulator` as Bedrock via `fetchAnthropicStream` in `anthropic-stream.ts`, so they stream too)
77
- - The OpenAI-family (`openai` / `deepseek` / `kimi` / `zhipu` / `doubao` / `hunyuan`) → `chat/completions` over HTTP with `stream: true` + `stream_options.include_usage`; the `chat.completion.chunk` SSE is folded back into the non-streaming `choices[0].message` shape by `fetchChatCompletionStream` in `openai-chat-stream.ts` (a transport helper, not a base class — each agent keeps its own body / message conversion / usage math), so they stream too
85
+ - The OpenAI-family (`openai` / `deepseek` / `kimi` / `zhipu` / `doubao` / `hunyuan`) → `chat/completions` over HTTP with `stream: true` + `stream_options.include_usage`; the `chat.completion.chunk` SSE is folded back into the non-streaming `choices[0].message` shape by `fetchChatCompletionStream` in `openai-chat-stream.ts` (a transport helper, not a base class; message/tool conversion and result parsing are shared via `openai-chat-format.ts`, while each agent keeps its own request body / thinking / usage math), so they stream too
78
86
  - Mantle (`aws-bedrock-mantle` / `aws-bedrock-openai`) → `MantleAgent`, OpenAI Responses API over HTTP with `stream: true`; `readResponsesStream` (module-level in `mantle-agent.ts`) reports `response.output_text.delta` frames live and takes the full final response object off the terminal `response.completed` / `response.incomplete` frame, so the existing `output[]` parse runs unchanged (bedrock-mantle sends no `[DONE]`, bedrock-runtime does — `readSseJson` skips it either way)
79
87
  - Every provider streams; the `onDelta` hook is still optional per provider, so a future non-streaming one just ignores it
80
88
 
@@ -107,11 +115,11 @@ Message dispatch:
107
115
 
108
116
  File: `ws/event-processor.ts` (pure functions).
109
117
 
110
- `sendWsNotification(event, state, turnId, ctx)` — converts OrchestratorEvent into a WS JSON message and sends it. Called *after* `applyEvent` has already mutated UIState. Does NOT mutate state itself. `bufferDetachedNotification(event, pendingEvents)` is the offline equivalent that buffers structural events for replay.
118
+ `sendWsNotification(event, state, turnId, ctx)` — converts OrchestratorEvent into a WS JSON message and sends it. Called *after* `applyEvent` has already mutated UIState. Does NOT mutate state itself. `bufferDetachedNotification(event, pendingEvents, sessionId)` is the offline equivalent that buffers structural events for replay.
111
119
 
112
120
  ### Broadcast — cross-client server-pushed events
113
121
 
114
- File: `ws/broadcast.ts`. Per-client `sendJson` is for chat-stream events that belong to one socket; `broadcast(event)` fans an event out to *every* connected admin client. Used for shared-state changes that any tab/browser cares about: evolution run state transitions, cron job/run state, channel binding changes. Replaces the `setInterval(fetch, ...)` polling pattern that earlier admin views used to detect server-side changes.
122
+ File: `ws/broadcast.ts`. Per-client `sendJson` is for chat-stream events that belong to one socket; `broadcast(event)` fans an event out to *every* connected admin client. Used for shared-state changes that any tab/browser cares about: evolution run state transitions, cron job/run state, session list changes, goal state, preview extensions. Replaces the `setInterval(fetch, ...)` polling pattern that earlier admin views used to detect server-side changes.
115
123
 
116
124
  `setBroadcastWss(wss)` is called once at server boot from `index.ts`; modules then `import { broadcast }` without threading the wss handle through. Callers that emit:
117
125
 
@@ -119,6 +127,10 @@ File: `ws/broadcast.ts`. Per-client `sendJson` is for chat-stream events that be
119
127
  - `routes/evolution.ts` — REST mutations (approve/reject/retry) emit immediately so user-action latency is "instant"
120
128
  - `cron/runner.ts` — `runJob` insert + `finalize` emit `cron:run_changed`; `reconcileFromDb` emits `cron:job_changed kind=reconciled|deleted` for out-of-band db edits (e.g. the cron skill writing the db directly)
121
129
  - `routes/cron.ts` — REST mutations emit `cron:job_changed kind=created|updated|deleted` immediately
130
+ - `agents/session-manager.ts` (root `createSession`), `agents/session-ui-store.ts` (root `complete`), `routes/sessions.ts` — `session:changed`
131
+ - `agents/goal-mode.ts` — `goal:changed`
132
+ - `extensions/watcher.ts` — `extension:changed` (full snapshot)
133
+ - `routes/git.ts` — `broadcastToWorkspace` a `.git` `file:changed` so every socket on that workspace refreshes
122
134
 
123
135
  ### UILogBuilder — UI state reducer
124
136
 
@@ -146,7 +158,7 @@ File: `agents/session-skill-commands.ts`. Stateless, pure reads. Resolves which
146
158
 
147
159
  ### SessionStateStore — rawMessages disk persistence
148
160
 
149
- File: `agents/session-state-store.ts`. Stateless. Saves/loads an agent's `rawMessages` (LLM-facing history) to its `.json` file via read-merge-write — the `rawMessages` half of session persistence (SessionUIStore owns the UI-log half; both write the same file). `saveAgentState` takes a narrow `SavableSession` (6 fields), not the full AgentSession. Host surface: workspaceRoot + `isSessionDeleted` (the tombstone short-circuit so a late save can't resurrect a deleted file).
161
+ File: `agents/session-state-store.ts`. Stateless. Saves/loads an agent's `rawMessages` (LLM-facing history) to its `.json` file via read-merge-write — the `rawMessages` half of session persistence (SessionUIStore owns the UI-log half; both write the same file). `saveAgentState` takes a narrow `SavableSession` (8 fields), not the full AgentSession. Host surface: workspaceRoot + `isSessionDeleted` (the tombstone short-circuit so a late save can't resurrect a deleted file).
150
162
 
151
163
  ## Helper modules
152
164
 
@@ -163,7 +175,7 @@ File: `prompts/md-loader.ts`. See [design/prompt-system.md](prompt-system.md).
163
175
  File: `tools/workspace-tools.ts`. 9 tools: file_read / view_image / file_write / file_edit / file_list / shell_exec / grep / glob / web_fetch. See [dev/tools.md](../dev/tools.md).
164
176
 
165
177
  ### Detached event buffer — post-disconnect replay
166
- File: `ws/handler.ts` (`cleanupConnection`) + `bufferDetachedNotification` in `ws/event-processor.ts`. After WS disconnect, an inline handler takes over the session tree's events and buffers structural ones for replay on reconnect. A *cleared* session (`/session new`) gets no handler at all — see [design/background-dispatch.md](background-dispatch.md).
178
+ File: `ws/handler.ts` (`cleanupConnection`) + `bufferDetachedNotification` in `ws/event-processor.ts`. After WS disconnect, an inline handler takes over the session tree's events and buffers structural ones for replay on reconnect. `/session new` only opens a draft tab — the previous session stays subscribed — see [design/background-dispatch.md](background-dispatch.md).
167
179
 
168
180
  ### WorkspaceWatcher — file watching
169
181
  File: `ws/file-watcher.ts`. `@parcel/watcher` recursive native subscription (one per workspace root, shared across connections via `ws/watcher-pool.ts`) → 300ms debounce + per-path Map dedup → callback. Ignores node_modules / .git / .next / dist etc. (`IGNORED_SEGMENTS`) — passed to parcel as the bare names (top-level `ignorePaths`, string compare) plus **one** `**/{a,b,…}/**` brace glob for nested occurrences. Not one glob per segment: parcel runs every ignore regex against every inode during the initial walk, and 52 separate regexes made subscribe on a ~26k-inode repo take ~13s (one brace regex: ~0.8s, same watch set). `.halo/sessions/` and `.halo/logs/` are **not** ignored — the dedup keeps volume low, and the front-end drops `change` events for files not open in the editor, so Explorer can reflect session deletions/creations while chat streaming stays cheap.
@@ -172,7 +184,7 @@ File: `ws/file-watcher.ts`. `@parcel/watcher` recursive native subscription (one
172
184
  File: `ws/terminal-manager.ts`. Spawns a shell via node-pty. On disconnect, detaches with a 50KB ring buffer; replays on reconnect.
173
185
 
174
186
  ### Self-Evolution — workspace prompt-tuning loop
175
- Files: `evolution/{ticker,evo-wrapper,enqueue,spawn,archive}.ts`, `db/evo-db.ts`, `routes/evolution.ts`. Internal agents `__evo_agent__` / `__score__` / `__apply_agent__` (in `templates/agents/`) drive a 12-phase orchestration: snapshot → evo drafts → wrapper dry-runs → scorer grades → reviewer approves → apply agent merges → wrapper history-snapshots + cps to main. Per-task wrapper Node child process owns all sub-cli calls; ticker is stateless and lives in the server. State in `~/.halo/global/evo.db`. See [plans/self-evolution.md](../plans/self-evolution.md).
187
+ Files: `evolution/{ticker,evo-wrapper,enqueue,spawn,archive}.ts`, `db/evo-db.ts`, `routes/evolution.ts`. Internal agents `__evo_agent__` / `__score__` / `__apply_agent__` (in `templates/agents/`) drive a 12-phase orchestration: snapshot → evo drafts → wrapper dry-runs → scorer grades → reviewer approves → apply agent merges → wrapper history-snapshots + cps to main. Per-task wrapper Node child process owns all sub-cli calls; ticker is stateless and lives in the server. State in `~/.halo/global/evo.db`. See [evolution.md](evolution.md).
176
188
 
177
189
  ### Run ledger — restart nudge for interrupted roots
178
190
  Files: `agents/run-ledger.ts` (sweep + skip rules) + `db/runs-db.ts` (the global `~/.halo/global/runs.db` table `running_sessions`, same singleton pattern as `evo.db`/`cron.db`). `runSession` inserts its session id on entry and deletes it in the finally (server-only, gated on `reconcileOrphansOnBoot`), so whatever is left at boot is exactly what the previous process was mid-run on; the sweep drains those rows and nudges each interrupted root. `index.ts` runs this eagerly: right after constructing the `SessionManagerRegistry` and before `bootChannels`, it loops `listRunningWorkspaces()` and claims+builds a `SessionManager` for each one that isn't already owned by another live server, rather than waiting for someone to open the workspace. See [design/session.md](session.md#run-ledger--restart-nudge-for-interrupted-roots-halo-globalrunsdb).
@@ -185,7 +197,7 @@ See [dev/api.md](../dev/api.md).
185
197
 
186
198
  Standalone terminal client — imports agent-core modules from `@turmind/halo-server` via subpath exports, bypassing all HTTP/WS infrastructure.
187
199
 
188
- Entry: `packages/cli/src/index.ts` → `harness.ts` (wraps SessionManager) → `cli.ts` (non-interactive) / `tui.ts` (interactive readline).
200
+ Entry: `packages/cli/src/index.ts` → `harness.ts` (wraps SessionManager) → `cli.ts` (non-interactive) / `tui.tsx` + `tui/` (interactive, ink/React).
189
201
 
190
202
  Uses `dispatchCommand()` from `channels/shared/commands.ts` for `/session new`, `/session list`, `/session switch`, `/session compact`, etc. Session prefix: `cli_`. Sessions are persisted identically to admin/channel sessions and are visible in the admin panel.
191
203
 
@@ -233,10 +245,8 @@ SQLite only holds metadata indexes; all content lives on the filesystem.
233
245
 
234
246
  | Medium | Writer | Contents |
235
247
  |---|---|---|
236
- | SQLite `sessions` | SessionRoutes, WS handler | Frontend session metadata |
237
248
  | SQLite `agent_sessions` | SessionManager | Agent session metadata (root + children; includes `working_dir` and `access_level` columns) |
238
249
  | `.halo/sessions/{agentId}/{sid}.json` | SessionManager, SessionStore | Session messages |
239
- | `.halo/sessions/{agentId}/{sid}.events.jsonl` | SessionManager | Event audit log |
240
250
  | `.halo/agents/{id}/agent.yaml` | AgentConfigRoutes | Agent YAML (workspace scope) |
241
251
  | `.halo/agents/{id}/AGENT.md` | AgentConfigRoutes | Agent personality |
242
252
  | `~/.halo/global/agents/{id}/agent.yaml` | AgentConfigRoutes | Agent YAML (global scope) |
@@ -246,12 +256,12 @@ SQLite only holds metadata indexes; all content lives on the filesystem.
246
256
  | `<project>/.halo/settings.yaml` | SettingsRoutes | Per-project overrides |
247
257
  | `~/.halo/global/models/<provider>.yaml` | Manual edit | Model registry — one file per provider, scanned at startup, used to dispatch to the matching runtime |
248
258
  | `~/.halo/global/prompts/{bootstrap,all,root}/*.md` | init.ts seed + user | System prompts |
249
- | `~/.halo/logs/server.log` or `<ws>/.halo/logs/server.log` | Logger | Server logs (10 MB rotation) |
250
- | `~/.halo/secrets/channels/channels.db` | All channels | Unified channel accounts (Telegram, Web, WeChat) — see [storage.md](storage.md#channel_accounts) |
259
+ | `~/.halo/global/logs/server.log` | Logger | Server logs (10 MB rotation) |
260
+ | `~/.halo/secrets/channels/channels.db` | All channels | Unified channel accounts (Web, Telegram, WeChat, Slack, Feishu, WeCom) — see [storage.md](storage.md#channel_accounts) |
251
261
 
252
262
  ## Coupling hot spots
253
263
 
254
- **SessionManager (~2260 lines)** — the central hub. Five concerns were carved out into one-directional sibling classes (see Key modules): UI-log/events (SessionUIStore), metadata queries (SessionQueryStore), agent construction (SessionAgentBuilder), skill-command permissions (SessionSkillCommands), rawMessages persistence (SessionStateStore). The carve-out is **complete** — what remains is the genuine high-cohesion core, all bound to the `sessions: Map` mutable state: the turn-execution loop, session lifecycle, the concurrency guards (`locks` init-mutex + `deletedSessionIds` delete-tombstone), and compaction.
264
+ **SessionManager (~3350 lines)** — the central hub. Five concerns were carved out into one-directional sibling classes (see Key modules): UI-log/events (SessionUIStore), metadata queries (SessionQueryStore), agent construction (SessionAgentBuilder), skill-command permissions (SessionSkillCommands), rawMessages persistence (SessionStateStore). The carve-out is **complete** — what remains is the genuine high-cohesion core, all bound to the `sessions: Map` mutable state: the turn-execution loop, session lifecycle, the concurrency guards (`locks` init-mutex + `deletedSessionIds` delete-tombstone), and compaction.
255
265
 
256
266
  Compaction stays in, by deliberate decision at two levels. Cluster-level: it's bidirectionally interwoven with the turn loop (a beforeCallModel hook that itself runs a turn) and mutates shared per-session state, so extraction would widen the host interface and tangle control flow. Function-level: `selfCompactSession` is one connected chain (compute cut → summarize via LLM → rebuild `[summary+recent]` → write back) — pulling the pure bits into compact.ts would sever a coherent single-use method and scatter it across files, trading cohesion for testability. A long, single-purpose, sequentially-coupled method is fine; the inherent complexity of session handling doesn't shrink by relocating it.
257
267
 
@@ -259,7 +269,7 @@ Compaction stays in, by deliberate decision at two levels. Cluster-level: it's b
259
269
 
260
270
  **Low: REST Routes / Core / tools** — no cross-dependencies; interact via DB or filesystem.
261
271
 
262
- **Dual writes**: Session data lives in both SQLite and the on-disk JSON; deletion must synchronise all three (SQLite → JSON → JSONL).
272
+ **Dual writes**: Session data lives in both SQLite and the on-disk JSON; deletion must synchronise both (SQLite → JSON + archive segments).
263
273
 
264
274
  ## Frontend (short version)
265
275
 
@@ -272,5 +282,5 @@ packages/admin
272
282
 
273
283
  Code structure:
274
284
  - `app/` — Next.js app router entry
275
- - `features/` — UI modules grouped by domain (agents / chat / editor / explorer / terminal / workspace / settings / skills / auth)
285
+ - `features/` — UI modules grouped by domain (agents / chat / editor / explorer / terminal / workspace / settings / skills / auth / source-control / cron / evolution / channels, plus one dir per channel)
276
286
  - `shared/` — cross-feature resources (stores / components / ws-handlers / ws-client / api-client / types / utils)
@@ -1,6 +1,6 @@
1
1
  # Command — Design
2
2
 
3
- Unified command processing layer — all channels (WS, WeChat, Telegram, Web, CLI/TUI) share a single dispatch path.
3
+ Unified command processing layer — all channels (WS, WeChat, Telegram, Slack, Feishu, WeCom, Web, CLI/TUI) share a single dispatch path.
4
4
 
5
5
  ## Architecture
6
6
 
@@ -32,9 +32,9 @@ WS handler special-cases `/session compact` because it needs UI progress callbac
32
32
  1. Channel receives a slash command from user input (or WS `command:<name>` message)
33
33
  2. Channel builds a `CommandContext` (shared interface: sm, userId, sessionPrefix, accessLevel, workspacePath, lang)
34
34
  3. Channel calls `dispatchCommand(ctx, '/command', args)`
35
- 4. `dispatchCommand` switch: `/help` and `/evo` call `exec*` directly; object commands (`/session`, `/agent`, `/skill`, `/workspace`) and the default case route through `routeObjectOrSkill`
35
+ 4. `dispatchCommand` switch: `/help` and `/evo` call `exec*` directly; object commands (`/session`, `/agent`, `/skill`, `/workspace`, `/goal` when goal mode is enabled) and the default case route through `routeObjectOrSkill`
36
36
  5. `routeObjectOrSkill` tries the builtin noun-verb table first (`SUBCOMMAND_ROUTES`, per-verb access via `verbAccessMap`), else falls through to `execSkillCommand` for the same-named skill — verb/permission model in [requirements/command.md](../requirements/command.md)
37
- 6. Returns `CommandResult { text, switchTo?, workspace? }` — channel formats and sends to user
37
+ 6. Returns `CommandResult { text, switchTo?, workspace?, startedTurn?, sessionId? }` — channel formats and sends to user
38
38
 
39
39
  `scanSkillDescriptors` drops skill commands that collide with a built-in (or another skill) at scan time, so dispatch and the discovery API stay consistent — see [requirements/command.md](../requirements/command.md#conflict-detection).
40
40
 
@@ -53,6 +53,6 @@ packages/server/src/channels/shared/
53
53
 
54
54
  ## Frontend integration
55
55
 
56
- - `features/chat/slash-commands.ts` — `refreshCommands(projectId)` pulls from REST API and merges client-only fallbacks
56
+ - `features/chat/slash-commands.ts` — `refreshCommands(projectId, sessionId?, agentId?)` pulls from REST API; a client-side fallback list is used only when that fails
57
57
  - `features/chat/use-chat.ts` — handles client-only commands locally; server commands sent as `command:<name>` via WS
58
- - `features/chat/chat-panel.tsx` — calls `refreshCommands()` on project switch
58
+ - `features/chat/chat-panel.tsx` — calls `refreshCommands()` on project / session / agent switch
@@ -101,8 +101,8 @@ interface CronChannelDispatcher {
101
101
  - no `supportsMedia` (telegram, feishu, wecom) → the **original text with `MEDIA:` lines intact** — the attachment degrades to a visible path instead of silently vanishing.
102
102
  - `CronMedia.workspacePath` is the **job's** workspace (where the run produced its files), not the target account's — it's the sandbox root for `isMediaPathAllowed` (paths outside workspace/OS-tmp are rejected with a failed result row). Cron passes no access level, so the `full`-account relaxation the chat handlers get does not apply here — the job workspace + OS tmp rule stays.
103
103
  - Wiring telegram/feishu up for real requires refactoring first, not copying: telegram's file send is an inline `sendPhoto/…/sendDocument` switch in `handler.ts`'s responder; feishu's `sendFeishuMedia` is anchored to an inbound `messageId` a cron run doesn't have. Extract the send-to-chat half in the channel module, then flip `supportsMedia: true` (details in each `cron-dispatcher.ts` header).
104
- - **Per-channel size limits are the dispatcher's job**, not the runner's: wechat splits the text at `WECHAT_TEXT_LIMIT` (3500 chars, shared `splitText` from `channels/shared/chunk.ts`) and sends the chunks in order — the ilink gateway rejects any single `sendmessage` over 16 KB with `ret=-2`. A failure on chunk *i* of *n* is recorded as `chunk i/n: <error>` in the target's `DispatchResult`, so the admin run row shows how much of the report already landed (a `failed` row no longer means "nothing arrived"). Telegram still sends one shot (Bot API caps at 4096) — same class of gap, unfixed.
105
- - **`chatId` semantics**: when a job is created from inside a chat, the target pins to that conversation (`chatId` set). When unset, the channel's own fan-out / default-recipient logic decides (e.g. Telegram fans out to its whitelist; Slack/Feishu/WeCom require an explicit `chatId`). WeCom is the one dispatcher with **no HTTP send path**: it borrows the handler's live long-connect `WSClient` (`liveClients`), so a stopped / kicked / auth-exhausted account fails the target loudly (`wecom long-connect not active`) instead of queueing.
104
+ - **Per-channel size limits are the dispatcher's job**, not the runner's: wechat splits the text at `WECHAT_TEXT_LIMIT` (3500 chars, shared `splitText` from `channels/shared/chunk.ts`) and sends the chunks in order — the ilink gateway rejects any single `sendmessage` over 16 KB with `ret=-2`. A failure on chunk *i* of *n* is recorded as `chunk i/n: <error>` in the target's `DispatchResult`, so the admin run row shows how much of the report already landed (a `failed` row no longer means "nothing arrived"). Telegram does the same at `TELEGRAM_TEXT_LIMIT` (4000 chars, the responder's limit — the Bot API caps a message at 4096), with the same `chunk i/n: <error>` failure row. The slack / feishu / wecom dispatchers still send the whole report as one message — same class of gap, unfixed.
105
+ - **`chatId` semantics**: when a job is created from inside a chat, the target pins to that conversation (`chatId` set). When unset, the channel's own default-recipient logic decides: WeChat falls back to the QR-bind owner, then the last active chat; Telegram/Slack/Feishu/WeCom require an explicit `chatId` and throw without one. WeCom is the one dispatcher with **no HTTP send path**: it borrows the handler's live long-connect `WSClient` (`liveClients`), so a stopped / kicked / auth-exhausted account fails the target loudly (`wecom long-connect not active`) instead of queueing.
106
106
  - `listTargets()` aggregates across all dispatchers (`listAllCronTargets`) to feed the admin create-form dropdown without the routes knowing any channel.
107
107
 
108
108
  ## Retention
@@ -37,7 +37,7 @@ Let agents draft and validate improvements to their own prompt surface by learni
37
37
  next user message uses patched rules
38
38
  ```
39
39
 
40
- All coordination via global db (`~/.halo/global/evo.db`). Wrapper heartbeats every 60s; ticker marks `running` → `timeout` if stale. Detached process model: server restart doesn't kill in-flight wrappers.
40
+ All coordination via global db (`~/.halo/global/evo.db`). Wrapper heartbeats every 60s; ticker requeues a stale `running` row (→ `timeout` once `max_attempts` is spent). Detached process model: server restart doesn't kill in-flight wrappers.
41
41
 
42
42
  ## Trigger and Enqueue Flow
43
43
 
@@ -52,12 +52,12 @@ User in chat:
52
52
  Server flow (synchronous, ~10ms):
53
53
 
54
54
  1. Refuse if user is not `full` access (not gated on `evolution.level`).
55
- 2. Resolve root session id (must be `parentId === null`).
55
+ 2. Resolve the active session id; a sub-session id (contains `>`) is rejected.
56
56
  3. Call `enqueueEvoRun()` from `/packages/server/src/evolution/enqueue.ts`:
57
57
  - mkdir `<ws>/.halo/evo/runs/<id>/`
58
58
  - Write `source-snapshot.json` (frozen `session.agent.messages`)
59
59
  - Write `tool-flow.md` (tool_result-stripped Markdown view for skimming)
60
- - Write `meta.json` (metadata: runId, triggerKind, sourceSession, userHint, createdAt)
60
+ - Write `meta.json` (metadata: id, triggerKind, sourceSession, userHint, workspacePath, createdAt)
61
61
  - Write `evo-context.json` (snapshot of prompt surface at trigger time: assembled system prompt, all prompt files, agent/skill listings)
62
62
  4. INSERT `evolution_runs` row with `status='pending'`, `trigger_kind='note'` (internal trigger id keeps the old name; only the user-visible command was renamed to `/evo`).
63
63
  5. Reply via `chat:system`: "📝 Queued for evaluation".
@@ -66,24 +66,22 @@ Server flow (synchronous, ~10ms):
66
66
 
67
67
  ### Pre-compact hook
68
68
 
69
- Inside `SessionManager.compactSession()`, **before** the compression LLM rewrites `session.agent.messages`:
69
+ Compaction only sets a per-session `compactedThisTurn` flag; `SessionManager.enqueueEvoForCompactedTurn()` consumes it once the compact is done — at the end of `runSession` for auto-compact (mid-turn or turn-end), right after the summary lands for manual `/compact`. A turn that compacts several times therefore enqueues one run.
70
70
 
71
- 1. Same checks (level=L1, root session, non-readonly user).
72
- 2. Snapshot the current messages → `enqueueEvoRun()` with `trigger='pre-compact'`.
71
+ 1. Checks: `evolution.level` = L1, `evolution.triggers.pre_compact` on, root session (`parentId` null).
72
+ 2. `enqueueEvoRun()` with `trigger='pre-compact'` snapshots the session's current `rawMessages`. Because the enqueue is deferred, that is the already-compacted history (summary + kept tail), not the pre-compact one.
73
73
  3. INSERT `evolution_runs` row.
74
- 4. Continue with normal compact.
75
-
76
- Order matters: snapshot first, compact second. Otherwise evo sees the already-compacted (summarized) message log, losing detail it should learn from.
77
74
 
78
75
  ## Ticker — Stateless Scheduler (every 30s)
79
76
 
80
77
  Located: `/packages/server/src/evolution/ticker.ts`
81
78
 
82
- Three jobs per pass:
79
+ Four jobs per pass:
83
80
 
84
- 1. **Mark timeouts**: `running` rows with heartbeat older than `runTimeoutMinutes` → `timeout`. Retry rows with `attempts < maxAttempts` back to `pending`; exhausted rows → terminal `timeout`.
81
+ 1. **Mark timeouts**: `running` rows (applies: also `syncing`) with heartbeat older than `runTimeoutMinutes` (`applyTimeoutMinutes` for applies) → back to `pending` while `attempts < maxAttempts`; exhausted rows → terminal `timeout` (a `syncing` apply → `failed`).
85
82
  2. **Start runs**: count current `running`, claim `pending` runs (oldest first, up to slot budget), spawn `evo-wrapper.js --mode=run --id=<X>`.
86
83
  3. **Start applies**: same shape, but with per-workspace mutex (only one apply per workspace at a time, since apply reads **current** main to build sandbox).
84
+ 4. **Broadcast**: diff in-flight run/apply statuses against the previous tick and push `evolution:run_changed` / `evolution:apply_changed` — wrappers are detached processes and can't reach the server's WS.
87
85
 
88
86
  Restart-safe: no in-memory state. On server reboot, ticker re-evaluates db on first tick — stale `running` rows timeout, pending rows continue to be picked up. Applies can resume mid-publish if they were in `syncing` state (heartbeat timeout was mid-phase-12).
89
87
 
@@ -95,7 +93,7 @@ The wrapper is the orchestrator; individual agents do focused work only. Every p
95
93
 
96
94
  ### Phase A — Draft
97
95
 
98
- `spawn('halo', ['cli', '-a', '__evo_agent__', '-n', '-w', '<runDir>/sandbox', '<draft-brief>'])`
96
+ `spawn('halo', ['cli', '-a', '__evo_agent__', '-n', '-w', '<runDir>/sandbox'])` — the brief goes in on stdin (multi-KB; argv would hit Windows limits), capped at `PHASE_TIMEOUT_SEC` (30 min).
99
97
 
100
98
  Agent writes:
101
99
  - `patch.md` — Markdown with YAML frontmatter + body. Frontmatter includes `testScenario: { agentId, testMessage, originalMessage }` and `target: .halo/<path>`.
@@ -106,20 +104,20 @@ The `target` can be any file in the prompt surface — `INSTRUCTIONS.md`, the ag
106
104
 
107
105
  Sandbox is whitelist-cp from main workspace (only: `INSTRUCTIONS.md`, `INDEX.md`, `USER.md`, `agents/`, `prompts/`, `skills/`, `docs/`). Agent reads via `file_read`, writes only to `.halo/` subset.
108
106
 
109
- The copy is `copyDereferenced` (`evo-wrapper.ts:1419`), a hand-rolled recursive walk that resolves every symlink to its target's bytes, so the sandbox holds contents and never a link back into the live workspace (an evo edit through a link would write straight into main). It replaced `fs.cpSync({ dereference: true })` because Node ≥ 22.17 ignores `dereference` for symlinks nested below the top level (nodejs/node#59168; fix merged 2026-09-18, unreleased at the time).
107
+ The copy is `copyDereferenced` in `evo-wrapper.ts`, a hand-rolled recursive walk that resolves every symlink to its target's bytes, so the sandbox holds contents and never a link back into the live workspace (an evo edit through a link would write straight into main). It replaced `fs.cpSync({ dereference: true })` because Node ≥ 22.17 ignores `dereference` for symlinks nested below the top level (nodejs/node#59168; fix merged 2026-09-18, unreleased at the time).
110
108
 
111
109
  ### Phase B — Dry-run + Fix Loop
112
110
 
113
111
  ```
114
- timeout 1800 halo cli -a <patch.testScenario.agentId> -n -w <runDir>/sandbox \
115
- --access workspace <patch.testScenario.testMessage>
112
+ halo cli -a <patch.testScenario.agentId> -n -w <runDir>/sandbox --access workspace
113
+ # testMessage on stdin; Node-side 1800s timer → exit 124
116
114
  ```
117
115
 
118
116
  `--access workspace` masks `~/.aws`, `~/.ssh`, etc. (safety: test probe's behavior, not side effects).
119
117
 
120
118
  Outcome:
121
119
  - **Success** (exit 0 + non-empty stdout): save to `dry-run-output.txt`, proceed to phase C.
122
- - **Failure**: save log, re-spawn `__evo_agent__` in fix mode with failure log inline. One corrective pass only (`FIX_BUDGET = 1`). If fix also fails, mark `failed` with reason "dry-run never succeeded".
120
+ - **Failure**: save log, re-spawn `__evo_agent__` in fix mode with failure log inline. One corrective pass only (`FIX_BUDGET = 1`). If fix also fails, mark `failed` with reason "phase B: dry-run failed (fix budget exhausted)".
123
121
 
124
122
  Fix budget design:
125
123
  - Two-pass (original + one fix) covers common failure modes (bad YAML, scope-too-aggressive test scenario).
@@ -127,7 +125,7 @@ Fix budget design:
127
125
 
128
126
  ### Phase C — Score
129
127
 
130
- `spawn('halo', ['cli', '-a', '__score__', '-n', '-w', '<runDir>/sandbox', '<score-brief>'])`
128
+ `spawn('halo', ['cli', '-a', '__score__', '-n', '-w', '<runDir>/sandbox'])` (brief on stdin)
131
129
 
132
130
  The score brief packs patch.md + dry-run-output.txt + meta.json + evo-context.json inline; the baseline conversation stays on disk (`tool-flow.md` / `source-snapshot.json`) and the brief directs the scorer to `file_read` it so behavior is graded against the actual baseline. Writes `score.json`:
133
131
 
@@ -160,7 +158,7 @@ After user approves N runs in the admin UI, `evolution_applies` row created with
160
158
 
161
159
  Wrapper builds **fresh sandbox** (whitelist-cp from **current main** workspace), then:
162
160
 
163
- `spawn('halo', ['cli', '-a', '__apply_agent__', '-n', '-w', '<applyDir>/sandbox', '<merge-brief>'])`
161
+ `spawn('halo', ['cli', '-a', '__apply_agent__', '-n', '-w', '<applyDir>/sandbox'])` (brief on stdin)
164
162
 
165
163
  Agent reads each source run's `patch.md` (latest version!), merges changes into sandbox. Respects platform override matrix (workspace replaces global wholesale for agents/, skills/, prompts/). Agent writes `apply.log` (audit trail), edits only under `sandbox/.halo/`.
166
164
 
@@ -176,13 +174,13 @@ The wrapper clears any stale ABORT.md (from a crashed previous attempt) at the s
176
174
  For each source_run_id:
177
175
 
178
176
  1. Read `testScenario` from source run's `patch.md`.
179
- 2. Run `timeout 1800 halo cli -a <agentId> -n -w <applyDir>/sandbox <testMessage>` against merged sandbox.
177
+ 2. Run `halo cli -a <agentId> -n -w <applyDir>/sandbox --access workspace` (testMessage on stdin, same 1800s timer) against merged sandbox.
180
178
  3. Spawn `__score__` with regress-mode brief, write `regress/<runId>/score.json`.
181
- 4. Any score with `lint < 50` or `behavior < 50` → regression, abort with `failed`.
179
+ 4. Any score with `lint < 50` or `behavior < 50` (or a missing / malformed `score.json`) → regression, abort with `failed`.
182
180
 
183
181
  ### Phase 12 — Final Sync (checkpointed)
184
182
 
185
- Three substeps, two of them checkpointed:
183
+ Four steps, with a checkpoint before the dangerous one:
186
184
 
187
185
  1. **Preflight** (idempotent): walk sandbox, diff against main, snapshot pre-apply files to `history/apply-<id>/` with `MANIFEST.json`.
188
186
  2. **Checkpoint**: set `evolution_applies.status='syncing'`. From here on, if wrapper crashes, ticker recovery resumes at step 3 (skip A'/B'/preflight to avoid re-running LLM phases).
@@ -216,6 +214,7 @@ Per-workspace mutex in ticker prevents two applies from racing on the same works
216
214
  # diagnosis; first line becomes failureReason)
217
215
  regress/<runId>/ # one per source run
218
216
  dry-run-output.txt
217
+ dry-run-fail.log # only when the regress dry-run failed
219
218
  score.json
220
219
  history/apply-<id>/ # rollback snapshot (NOT archived)
221
220
  MANIFEST.json # which paths were overwritten
@@ -270,7 +269,7 @@ Active rows (pending/running/awaiting_review/approved/syncing) never archived. `
270
269
 
271
270
  Archive daemon runs at server boot (1 min delay) + daily. Idempotent.
272
271
 
273
- Zipping is **pure JS** (`jszip`, already a repo dependency via admin). It used to `spawnSync('zip', …)`, which depends on a system binary Windows installs don't have — so every archive pass failed there forever and artifacts grew unbounded. `zipDir()` walks the tree manually rather than using a recursive readdir, because the layout has to match what `zip -rq <out> .` produced and two cases need explicit handling: empty dirs get explicit folder entries, and **directory symlinks are not recursed** (an agent runs inside the sandbox during evaluation and could leave a cycle). File symlinks store the target's contents (what system `zip` without `-y` did); broken links are skipped instead of failing the pass. Contents are buffered per file (`createReadStream` for every entry would open all fds up front — EMFILE risk) and the zip is streamed to disk. On failure the partial zip is deleted, and `archiveRun`/`archiveApply` skip both the `rmDir` and the `archived_at` stamp, so the next pass retries from a clean state.
272
+ Zipping is **pure JS** (`jszip`, a server dependency). It used to `spawnSync('zip', …)`, which depends on a system binary Windows installs don't have — so every archive pass failed there forever and artifacts grew unbounded. `zipDir()` walks the tree manually rather than using a recursive readdir, because the layout has to match what `zip -rq <out> .` produced and two cases need explicit handling: empty dirs get explicit folder entries, and **directory symlinks are not recursed** (an agent runs inside the sandbox during evaluation and could leave a cycle). File symlinks store the target's contents (what system `zip` without `-y` did); broken links are skipped instead of failing the pass. Contents are buffered per file (`createReadStream` for every entry would open all fds up front — EMFILE risk) and the zip is streamed to disk. On failure the partial zip is deleted, and `archiveRun`/`archiveApply` skip both the `rmDir` and the `archived_at` stamp, so the next pass retries from a clean state.
274
273
 
275
274
  ## Integration with SessionManager
276
275
 
@@ -280,13 +279,13 @@ After apply publishes to main, **no explicit session-release step needed**. Sess
280
279
 
281
280
  ## Key Files and Their Roles
282
281
 
283
- | File | Lines | Purpose |
284
- |---|---|---|
285
- | `/packages/server/src/evolution/enqueue.ts` | 403 | Snapshot session + prompt surface at trigger time. Write runDir/, INSERT evolution_runs. Both `/evo` and pre-compact hook call this. |
286
- | `/packages/server/src/evolution/ticker.ts` | 496 | Stateless 30s scheduler. Mark timeouts, claim pending, spawn wrappers. Per-workspace apply mutex. Broadcast status changes to admin UI. |
287
- | `/packages/server/src/evolution/spawn.ts` | 36 | Real spawner: detached Node child running evo-wrapper.js. Override-able for testing. |
288
- | `/packages/server/src/evolution/evo-wrapper.ts` | 2000+ | Wrapper orchestrator. 3-phase run mode (draft/dry-run/score). Apply mode (merge/regress/publish). Heartbeat every 60s. Handles Windows command-line limits via stdin briefs. |
289
- | `/packages/server/src/evolution/archive.ts` | 313 | Archive job: 14d → zip (pure-JS jszip) + delete, 30d → purge. Runs daily + at boot. Idempotent. |
282
+ | File | Purpose |
283
+ |---|---|
284
+ | `/packages/server/src/evolution/enqueue.ts` | Snapshot session + prompt surface at trigger time. Write runDir/, INSERT evolution_runs. Both `/evo` and pre-compact hook call this. |
285
+ | `/packages/server/src/evolution/ticker.ts` | Stateless 30s scheduler. Mark timeouts, claim pending, spawn wrappers. Per-workspace apply mutex. Broadcast status changes to admin UI. |
286
+ | `/packages/server/src/evolution/spawn.ts` | Real spawner: detached Node child running evo-wrapper.js. Override-able for testing. |
287
+ | `/packages/server/src/evolution/evo-wrapper.ts` | Wrapper orchestrator. 3-phase run mode (draft/dry-run/score). Apply mode (merge/regress/publish). Heartbeat every 60s. Handles Windows command-line limits via stdin briefs. |
288
+ | `/packages/server/src/evolution/archive.ts` | Archive job: 14d → zip (pure-JS jszip) + delete, 30d → purge. Runs daily + at boot. Idempotent. |
290
289
 
291
290
  ## Sandbox Model
292
291
 
@@ -322,14 +321,14 @@ L0 = manual drafting only (`/evo`); L1 also enables pre-compact triggering. Defa
322
321
 
323
322
  Top-level "Evolution" tab shows:
324
323
 
325
- - **List**: evolution_runs + latest apply per run. Sortable by created_at / status / score.avg. Filterable by status. Joins runs+applies to show consolidated view.
326
- - **Detail**: patch.md, score.json, test scenario, assembled brief context, diff against current target.
327
- - **Approve**: Opens dialog, optional reviewer_hint. Flips the run to `approved` **and** inserts the pending `evolution_applies` row **inside one `db.transaction`** — it's a single reviewer decision, and half of it (run flipped, apply missing) strands the run in a status the ticker never advances.
324
+ - **List**: evolution_runs + latest apply per run, newest first (cursor-paginated by `created_at`, up to 300 loaded). Filterable by status, plus an `archived` view. Joins runs+applies to show consolidated view.
325
+ - **Detail**: patch.md (raw), score.json, the `.skip.md` reason, a snapshot summary (message count, first user / assistant message), wrapper log + sub-cli log.
326
+ - **Approve**: Opens an inline panel, optional reviewer_hint. Flips the run to `approved` **and** inserts the pending `evolution_applies` row **inside one `db.transaction`** — it's a single reviewer decision, and half of it (run flipped, apply missing) strands the run in a status the ticker never advances.
328
327
  - **Reject**: Sets status='rejected'.
329
- - **Retry**: Resets row to pending, requires new hint. Allowed from any terminal status except `applied`.
330
- - **Manual Delete**: Removes artifacts (live dir + archive zip, whichever exists) + DB row immediately. Blocked on active rows (pending/running/approved/syncing).
328
+ - **Retry**: Resets row to pending, requires new hint. The route allows any status except `pending` / `running`; the UI offers it on `awaiting_review`, `failed` / `timeout` / `skipped` / `rejected`, and on an `approved` run whose apply failed.
329
+ - **Manual Delete**: Removes artifacts (live dir + archive zip, whichever exists) + DB row immediately. Blocked on `pending` / `running` / `approved` runs; also deletes the applies the run produced.
331
330
 
332
- Realtime updates via WebSocket (`evolution:run_changed`, `evolution:apply_changed` events). REST mutations broadcast directly; wrapper state changes broadcast at each ticker tick (15-30s latency).
331
+ Realtime updates via WebSocket (`evolution:run_changed`, `evolution:apply_changed` events). REST mutations broadcast directly; wrapper state changes broadcast at each ticker tick (up to 30s latency).
333
332
 
334
333
  ## Crash Recovery and Restarts
335
334
 
@@ -29,10 +29,12 @@ The marker is **stripped from rendered chat** so the user sees only the face mov
29
29
 
30
30
  **Runtime expression:** Agent emits `<<<SHOW: payload >>>` in a reply. On `chat:complete`:
31
31
  1. `chat-handlers.ts` takes the round's replies with `takeRoundReplies()` (`chat-store.ts`) — every main assistant bubble since the previous `chat:complete`, in log order, each handed out once. A round can span several bubbles (the head an interjection split off, a turnId split, the follow-up answering a queued message), so scanning only the last bubble would miss earlier markers
32
- 2. `maybeHandleShow(replies)` detects all `<<<SHOW:[\s\S]*?>>>` markers (non-greedy, global) in those bubbles; `maybeHandleCapture(wsClient, replies)` looks for `<<<CAPTURE>>>` in the same set
32
+ 2. `maybeHandleShow(replies)` detects all `<<<SHOW:[\s\S]*?>>>` markers (non-greedy, global) in those bubbles; `maybeHandleCapture(wsClient, tabStore, replies)` looks for `<<<CAPTURE>>>` in the same set
33
33
  3. For each match, extracts the payload (trimmed)
34
34
  4. Calls `postToFace(payload)` to forward it
35
35
 
36
+ `takeRoundReplies()` runs on every tab (it resets that tab's round), but steps 2–4 run only for the tab on screen — a background session must not drive the face the user is looking at.
37
+
36
38
  **No dedup set.** `takeRoundReplies()` advances a cursor (`roundStart`) past what it returned, so the duplicate `chat:complete`s a queue drain emits find no new bubbles and fire nothing. `setMessages` (history load, snapshot restore, reattach) and `clear()` reset the cursor to the end of the loaded log, so markers in already-loaded history never fire.
37
39
 
38
40
  **Forwarding:** `face-bridge.ts:postToFace()` posts to every registered iframe:
@@ -58,7 +60,7 @@ parsed.replace(/<<<CAPTURE>>>/g, '')
58
60
 
59
61
  ## The `self` API
60
62
 
61
- Provided by `self.html` from line 432 onwards (`const self = {…}`). All expression methods are sandboxed to the preview iframe.
63
+ Provided by `self.html` as `const self = {…}`. All expression methods are sandboxed to the preview iframe.
62
64
 
63
65
  ### Scene queuing (sequential playback)
64
66
 
@@ -87,12 +89,12 @@ Provided by `self.html` from line 432 onwards (`const self = {…}`). All expres
87
89
 
88
90
  ## Key files
89
91
 
90
- - **Engine template:** `packages/server/templates/canvas/self.html` — particle field, mode switching, API surface, voice audio graph. ~630 lines. Canonical source; force-copied to every workspace on open.
92
+ - **Engine template:** `packages/server/templates/canvas/self.html` — particle field, mode switching, API surface, voice audio graph. Canonical source; force-copied to every workspace on open.
91
93
  - **Skill instruction:** `packages/server/templates/skills/self/SKILL.md` — teaches the agent when/how to use the face.
92
94
  - **Marker detection:** `packages/admin/src/shared/ws-handlers/chat-handlers.ts:maybeHandleShow()` — regex match `<<<SHOW:([\s\S]*?)>>>` on the round's replies at `chat:complete`; the replies come from `takeRoundReplies()` in `chat-store.ts`, which hands each bubble out once.
93
95
  - **Iframe registration:** `packages/admin/src/features/editor/face-bridge.ts` — module-level registry of mounted previews; `postToFace()` forwards payloads via `postMessage`.
94
96
  - **Preview component:** `packages/admin/src/features/editor/html-preview.tsx` — sandboxed iframe with `allow-scripts` + `allow-same-origin` and `allow="autoplay"` (so `self.voice` audio, triggered by postMessage rather than a click, isn't gated), calls `registerFaceIframe()` on mount.
95
- - **Marker stripping:** `packages/admin/src/shared/components/message-list.tsx:TextBlock()` (line 630) — strips both `<<<CAPTURE>>>` and `<<<SHOW:...>>>` before rendering.
97
+ - **Marker stripping:** `packages/admin/src/shared/components/message-list.tsx:TextBlock()` — strips both `<<<CAPTURE>>>` and `<<<SHOW:...>>>` before rendering.
96
98
  - **Workspace init:** `packages/server/src/init.ts:ensureWorkspaceHalo()` — force-copies engine on workspace open. `self` is in `BUILTIN_SKILL_IDS` so the skill is always available.
97
99
 
98
100
  ## Engine architecture
@@ -14,7 +14,7 @@ Halo server (9527) ──┤├── channels/wechat/ ├── Se
14
14
  https://open.feishu.cn
15
15
  ```
16
16
 
17
- Feishu uses the official SDK's `Lark.WSClient` to maintain a persistent WebSocket connection for event delivery. Unlike Slack/Telegram (which use webhooks + polling respectively), Feishu's long-connect receives `im.message.receive_v1` events pushed by the server over wss.
17
+ Feishu uses the official SDK's `Lark.WSClient` to maintain a persistent WebSocket connection for event delivery. Unlike Telegram (long-polling), Feishu's long-connect receives `im.message.receive_v1` events pushed by the server over wss.
18
18
 
19
19
  ## Data model
20
20
 
@@ -70,7 +70,7 @@ Files: `packages/server/src/channels/feishu/`
70
70
  - `accounts.ts` — DAL (listAccounts / getAccount / insertAccount / updateAccount / deleteAccount / findAccountByAppId)
71
71
  - `handler.ts` — Lark.WSClient setup, event dispatch, message handling, mention detection, media ingestion; session routing + listener/route bookkeeping come from `channels/shared/inbound.ts` (`InboundBridge` / `deliverInbound` / `dispatchChannelCommand`)
72
72
  - `event-adapter.ts` — AgentSessionEvent stream → coalesced Feishu message replies (buffer + flush at paragraph boundaries)
73
- - `api.ts` — HTTP client (tenant token caching / getBotInfo / sendMessage / replyMessage / uploadImage / uploadFile / downloadResource / decryptWebhookBody / searchFeishuTargets / openLongConnection)
73
+ - `api.ts` — HTTP client (tenant token caching / getBotInfo / sendMessage / replyMessage / uploadImage / uploadFile / downloadResource / decryptWebhookBody / searchFeishuTargets / openLongConnection); authenticated calls go through `fetchWithTenantToken` (401 → drop the cached token, retry once) and replies are parsed by `readFeishuJson`
74
74
  - `cron-dispatcher.ts` — registers the cron dispatcher, requires explicit chatId targets
75
75
  - `descriptor.ts` — ServerChannelDescriptor entry point
76
76
 
@@ -122,7 +122,7 @@ The SDK wraps protobuf marshalling; we just register an `EventDispatcher` callba
122
122
 
123
123
  ## Slash commands (native Feishu /commands)
124
124
 
125
- Implemented via `channels/shared/commands.ts` (shared across Telegram, WeChat, Slack, Feishu):
125
+ Implemented via `channels/shared/commands.ts` (shared across Telegram, WeChat, Slack, Feishu, WeCom):
126
126
 
127
127
  | Command | Purpose |
128
128
  |---|---|
@@ -152,7 +152,9 @@ splitting produces several chunks in one synchronous loop and each is its own
152
152
  send API call, so firing them concurrently gave arrival order no guarantee. Each
153
153
  responder appends every chunk to a `sendTail` promise chain — chunk *n+1* waits
154
154
  for *n* to settle — and a rejected link is absorbed so one failed send doesn't
155
- stall the remainder (`dispatchChunk` logs it).
155
+ stall the remainder (`dispatchChunk` logs it). Both live in the shared
156
+ `ChunkedResponder` (`channels/shared/responder.ts`); `FeishuResponder` only
157
+ supplies the 4500 limit and `formatForFeishu`.
156
158
 
157
159
  `close()` returns that drain promise and `InboundBridge` holds the session's
158
160
  reply route until it settles, then deletes it; the `stopAccount` / `closeAll()`
@@ -170,7 +172,7 @@ trailing chunks of a long reply were dropped.
170
172
  - `.mp4/.mov/.webm/.m4v/.avi` → `uploadFile(fileType: 'mp4')` → `msg_type: 'media'` (with optional cover frame)
171
173
  - everything else → `uploadFile(fileType: 'stream')` → `msg_type: 'file'`
172
174
 
173
- 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 throws inside the same try as the upload, so it surfaces as the `t('handler.upload_failed')` reply rather than a silent drop.
175
+ 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 throws inside the same try as the upload (`sendMediaOrReport` in `channels/shared/media.ts`), so it surfaces as the `t('handler.upload_failed')` reply rather than a silent drop.
174
176
 
175
177
  Inbound images are downloaded via `/im/v1/messages/{messageId}/resources/{imageKey}?type=image`, decrypted if needed, saved under `<workspace>/.halo/assets/feishu/inbound/<accountId>/<date>/`, and fed to vision.
176
178
 
@@ -180,17 +182,19 @@ Inbound images are downloaded via `/im/v1/messages/{messageId}/resources/{imageK
180
182
 
181
183
  **Group (chat):** Replies sent via `replyMessage` with `reply_in_thread: true` and the inbound message id. This auto-creates a thread rooted at the inbound message if it doesn't exist, or appends to the existing thread.
182
184
 
185
+ **After a restart**, `startAccount` calls `restoreReplyRoute()` to re-wire the reply route from `lastActiveChatId` (`restoreChannelRoute` in `channels/shared/inbound.ts`, no session created). p2p only: a group-thread reply needs the inbound message id, which isn't persisted, so group threads re-wire on their next inbound message.
186
+
183
187
  ## Similarities vs Slack and Telegram
184
188
 
185
189
  | Aspect | Feishu | Slack | Telegram |
186
190
  |--------|--------|-------|----------|
187
- | **Inbound delivery** | Long-connect (wss) | Webhook + slash commands | Long-polling |
191
+ | **Inbound delivery** | Long-connect (wss) | Socket Mode (wss) | Long-polling |
188
192
  | **Session model** | Per-thread in groups, p2p anchored | Per-thread + channels | Per-user (one active) |
189
193
  | **Mention required** | Yes in groups, no in p2p | Yes in channels, no in DMs | No (all messages routed to agent) |
190
194
  | **Cron target** | Explicit chatId only | Explicit chatId only (DM / channel / thread) | Explicit numeric chatId only |
191
195
  | **Media upload** | Separate image + file endpoints | Unified File upload API | Bot API sendPhoto / sendVideo / sendDocument |
192
- | **Text limit** | ~5000 chars | ~4000 chars | ~4000 chars |
193
- | **Thread support** | Native (root_id in messages) | Native (thread_ts replies) | N/A (group chats not supported) |
196
+ | **Text limit** | 4500 chars per message | 35000 chars per message | 4000 chars per message |
197
+ | **Thread support** | Native (root_id in messages) | Native (thread_ts replies) | N/A (group chats supported, no threads; sessions keyed by sender) |
194
198
 
195
199
  ## Configuration
196
200
 
@@ -230,10 +234,10 @@ Not supported: group chat without mention (we require explicit @mention for grou
230
234
 
231
235
  ## Key file references
232
236
 
233
- - Long-connect: `packages/server/src/channels/feishu/handler.ts:connect()` (line 241)
234
- - Token caching: `packages/server/src/channels/feishu/api.ts:getTenantAccessToken()` (line 39)
235
- - Message parsing: `packages/server/src/channels/feishu/handler.ts:parseContent()` (line 135)
236
- - Event dispatch: `packages/server/src/channels/feishu/handler.ts:handleInbound()` (line 301)
237
- - Cron dispatch: `packages/server/src/channels/feishu/cron-dispatcher.ts:dispatch()` (line 27)
238
- - Event coalescing: `packages/server/src/channels/feishu/event-adapter.ts:FeishuResponder` (line 24)
237
+ - Long-connect: `packages/server/src/channels/feishu/handler.ts` `connect()`
238
+ - Token caching: `packages/server/src/channels/feishu/api.ts` `getTenantAccessToken()`
239
+ - Message parsing: `packages/server/src/channels/feishu/handler.ts` `parseContent()`
240
+ - Event dispatch: `packages/server/src/channels/feishu/handler.ts` `handleInbound()`
241
+ - Cron dispatch: `packages/server/src/channels/feishu/cron-dispatcher.ts` `dispatch()`
242
+ - Event coalescing: `packages/server/src/channels/feishu/event-adapter.ts` `FeishuResponder`, extending `channels/shared/responder.ts` `ChunkedResponder`
239
243
  - REST routes: `packages/server/src/routes/feishu.ts` (all account CRUD + search)
@@ -65,7 +65,7 @@ Halo 服务器运行态的**街景式**像素可视化:侧视、剖面的像素
65
65
  | 🛸 UFO | 每天 0:00 | 午夜从街头飘过 |
66
66
  | ✈️ 飞机 | 每半点(`:30`) | 高空掠过,方向随机 |
67
67
  | 🚌 巴士 | 每 3 分钟 | 进站、开门、接走在站台等车的人,再开走 |
68
- | 🚗 进出场载具 | 跟随会话 | 新会话打车/电动车/跑车送到门口;结束的会话走到站台**体面地等车离场**,不再凭空消失 |
68
+ | 🚗 进出场载具 | 跟随会话 | 新根会话坐出租车或小轿车送到门口(子代理直接淡入,不配车);结束的会话走到站台**体面地等车离场**,不再凭空消失 |
69
69
  | 🏗️ 盖楼 / 拆楼 | 跟随楼层增减 | 长楼层有施工动效;拆楼留 ~30s 缓冲(先让人撤离再慢慢消散,不穿帮) |
70
70
 
71
71
  > 定时全用真实 `Date.now()` 计算(`?hour=` 只冻结天色、不冻结这些事件)。
@@ -73,8 +73,8 @@ Halo 服务器运行态的**街景式**像素可视化:侧视、剖面的像素
73
73
  ## 交互与显示
74
74
 
75
75
  操作:滚轮缩放(九档 `0.3×–6×`)· 拖拽平移 · 点击查看 · **双击**在 1×/3× 间快速切 ·
76
- **F** 全街区 · **H**(或点左上角 logo)切纯净模式 · **Esc** 逐层退(先关面板,再退纯净)·
77
- 右上角 **中/EN** 一键切语言。闲置 10s 后镜头沿街慢慢巡游。
76
+ **F** 全街区 · **H**(或点左上角 logo)切纯净模式 · **Esc** 先关面板,没有面板时切纯净模式 ·
77
+ 右上角 **中/EN** 一键切语言。
78
78
 
79
79
  - **纯净模式(zen)**:淡出所有 HUD(统计/动态/图例/缩放条/面板),只留街景,适合大屏常驻展示
80
80
  - **多语言(i18n)**:中文 / English 一键切;`?lang=en`/`?lang=zh` 直达,选择存 localStorage。
@@ -104,6 +104,7 @@ js/
104
104
  camera.js 整数吸附镜头(九档缩放 0.3×–6×)
105
105
  inspector.js 面板 + 实时会话日志查看器
106
106
  ticker.js 街区动态 feed
107
+ music.js 背景音乐:纯 Web Audio 程序生成
107
108
  traffic.js 街景事件:UFO(0点)/飞机(每半点)/进出场载具/每 3 分钟 bus/盖楼·拆楼
108
109
  i18n.js 多语言词典(zh/en)+ 运行时 t() + 静态 data-i18n 填充
109
110
  util.js hash/PRNG/格式化
@@ -117,7 +118,7 @@ js/
117
118
  token 铸造的会话**(id 以 `web_<accountId>_` 开头);点其它市民会 403,
118
119
  前端在 inspector 里显示加载失败框而不是 transcript
119
120
  - `/api/show/state` — 无活跃 UIState 的会话(idle/stopped/重启后)token
120
- 从会话文件头读取(mtime 缓存,不随轮询刷盘),不显示 0;含 `messageCount` 字段
121
+ 从会话文件头读取(按会话 `updatedAt` 缓存,未变化时不随轮询重读),不显示 0;含 `messageCount` 字段
121
122
 
122
123
  > **`messageCount` 与 admin 的 `exchangeCount` 是两个语义,不要互相对齐**:
123
124
  > 这里读的是会话文件里的 `messageCount`(活跃 UI 日志的长度),城市只把它