@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
@@ -34,14 +34,14 @@ No `OTEL_EXPORTER_OTLP_ENDPOINT` after the mapping → `enabled=false`, `initObs
34
34
 
35
35
  | module | role |
36
36
  |---|---|
37
- | `packages/server/src/observability/otel.ts` (91 lines) | Bootstrap. `initObservability()` / `shutdownObservability()`, the `enabled` gate, `tracer` / `getMeter()` / `otelLogger` / `captureContent()` exports. |
38
- | `packages/server/src/observability/otel-sdk.ts` (47 lines) | The ONE module that imports the SDK packages and OTLP exporters. |
39
- | `packages/server/src/observability/genai-spans.ts` (296 lines) | The span model: `beginTurn` / `onAgentEvent` / `recordRetry` / `endTurn`. |
37
+ | `packages/server/src/observability/otel.ts` | Bootstrap. `initObservability()` / `shutdownObservability()`, the `enabled` gate, `tracer` / `getMeter()` / `otelLogger` / `captureContent()` exports. |
38
+ | `packages/server/src/observability/otel-sdk.ts` | The ONE module that imports the SDK packages and OTLP exporters. |
39
+ | `packages/server/src/observability/genai-spans.ts` | The span model: `beginTurn` / `onAgentEvent` / `recordRetry` / `endTurn`. |
40
40
  | `packages/server/src/agents/session-manager.ts` | Calls the four hooks above from the turn loop (see below). |
41
41
  | `packages/server/src/logger.ts` | Bridges every `logger.*` call into an OTel LogRecord when `enabled`. |
42
- | `packages/server/src/index.ts` | `await initObservability()` (L262, before `initLogger()`) and `await shutdownObservability()` (L599, in `gracefulShutdown`). |
42
+ | `packages/server/src/index.ts` | `await initObservability()` (before `initLogger()`) and `await shutdownObservability()` (in `gracefulShutdown`). |
43
43
 
44
- **Hook call sites in `session-manager.ts`**: `beginTurn(session, message)` at L1276 (start of `runAgentTurn`), `onAgentEvent(session, event)` at L1316 (inside the agent event loop, one call per `AgentEvent`), `recordRetry(kind)` at the 7 retry catch sites, `endTurn(session, {error?})` at L1547 (the turn's single exit point, success or failure).
44
+ **Hook call sites in `session-manager.ts`** (all in `runAgentTurn`): `beginTurn(session, message)` at the start, `onAgentEvent(session, event)` inside the agent event loop (one call per `AgentEvent`), `recordRetry(kind)` at the 7 retry catch sites, `endTurn(session, {error?})` once after the retry loop (the turn's single exit point, success or failure).
45
45
 
46
46
  **The `enabled` gate.** `otel.ts` exports a single `enabled: boolean`, set once by `initObservability()`. Every hook in `genai-spans.ts` and the `logger.ts` bridge checks it first — an unconfigured server pays nothing beyond one boolean read per hook call.
47
47
 
@@ -49,9 +49,9 @@ No `OTEL_EXPORTER_OTLP_ENDPOINT` after the mapping → `enabled=false`, `initObs
49
49
 
50
50
  **Why the meter is lazy.** The `@opentelemetry/api` tracer and logger are proxies — they stay no-op until a real provider is registered, then transparently start forwarding, so `tracer` / `otelLogger` can be module-level consts. The metrics API has **no proxy**: a `Meter` fetched via `metrics.getMeter()` before `setGlobalMeterProvider()` runs is a permanent no-op forever after, even once a provider registers later. `getMeter()` in `otel.ts` is therefore a function, not a const, and `genai-spans.ts` calls it lazily on first metric emission (`instruments()`, cached after).
51
51
 
52
- **Dependencies** (`packages/server/package.json`): `@opentelemetry/api` 1.9.1, `api-logs` 0.222.0, `sdk-trace-node` / `sdk-trace-base` / `sdk-metrics` / `sdk-logs` / `resources` 2.11.0, `exporter-trace-otlp-proto` / `exporter-metrics-otlp-proto` / `exporter-logs-otlp-proto` 0.222.0, `semantic-conventions` 1.43.0. No gRPC exporter (see [Rejected alternatives](#rejected-alternatives)).
52
+ **Dependencies** (`packages/server/package.json`): `@opentelemetry/api` 1.9.1, `api-logs` 0.222.0, `sdk-trace-node` / `sdk-trace-base` / `sdk-metrics` / `resources` 2.11.0, `sdk-logs` / `exporter-trace-otlp-proto` / `exporter-metrics-otlp-proto` / `exporter-logs-otlp-proto` 0.222.0, `semantic-conventions` 1.43.0. No gRPC exporter (see [Rejected alternatives](#rejected-alternatives)).
53
53
 
54
- **Test coverage**: `packages/server/test/observability-genai-spans.test.ts` (7 cases) drives `beginTurn` / `onAgentEvent` / `endTurn` against an in-memory tracer + `InMemorySpanExporter`, asserting the three-level span tree, parent ids, semconv attribute names, and the `capture_content` on/off split.
54
+ **Test coverage**: `packages/server/test/observability-genai-spans.test.ts` (8 cases) drives `beginTurn` / `onAgentEvent` / `endTurn` against an in-memory tracer + `InMemorySpanExporter`, asserting the three-level span tree, parent ids, semconv attribute names, and the `capture_content` on/off split.
55
55
 
56
56
  ## Span model
57
57
 
@@ -87,7 +87,7 @@ Child of `invoke_agent`, one per model call — created retroactively on the `us
87
87
  | `gen_ai.input.messages` | | semconv JSON (see below) — **delta only**: messages appended since this turn's previous `chat` span (first call → the user message; later calls → that cycle's `tool_result`s) |
88
88
  | `gen_ai.output.messages` | | semconv JSON, only the trailing assistant message |
89
89
 
90
- Semconv message JSON: `[{role, parts:[{type:"text",content}|{type:"tool_call",id,name,arguments}|{type:"tool_call_response",id,result}]}]`.
90
+ Semconv message JSON: `[{role, parts:[{type:"text",content}|{type:"tool_call",id,name,arguments}|{type:"tool_call_response",id,response}]}]`.
91
91
 
92
92
  `gen_ai.input.messages` is incremental on purpose (since 1.3.1): replaying the full history on every model call made a turn's exported bytes O(n²) and the system prompt alone was ~60% of the volume — a 4-turn / 9-call session dropped from 129 KB to 44 KB of content attributes. Nothing is lost: the full conversation for a trace is the concatenation of its `chat` spans' input + output deltas in order (a `TurnState.messageCursor` tracks the boundary; a mid-turn compact that shrinks the history yields an empty delta and re-syncs the cursor). Verified that AgentCore Evaluations scores are unchanged — its evaluators read `invoke_agent`'s `task.*` and `execute_tool`'s `arguments/result`, not the chat messages.
93
93
 
@@ -105,7 +105,7 @@ Child of the `chat` span that requested it, one per tool call — created on the
105
105
  | `gen_ai.tool.call.arguments` | | JSON |
106
106
  | `gen_ai.tool.call.result` | | text, empty → `[no output]` |
107
107
 
108
- Tool calls still pending when `endTurn` fires (turn interrupted/aborted mid-cycle) are closed as orphans with status `ERROR` and `halo.tool.orphaned=true`, so no trace is left with a dangling tool-call id.
108
+ Tool calls still pending when `endTurn` fires (turn interrupted/aborted mid-cycle) are closed as orphans (no status set) with `halo.tool.orphaned=true`, so no trace is left with a dangling tool-call id.
109
109
 
110
110
  All content attributes are capped (32 KB general cap, 2 KB per message part, 8 KB for system instructions); over-cap content is truncated with a `…[truncated N chars]` marker, and an over-cap message list drops the oldest messages first with an omission marker.
111
111
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  On agent startup, Halo assembles the system prompt by concatenating various MD files in a fixed order.
4
4
 
5
- Entry point: `session-manager.ts` `buildAgentInstance(agentId, sessionId, parentId?, workingDir?, accessLevel?)`
5
+ Entry point: `session-agent-builder.ts` `SessionAgentBuilder.buildAgentInstance(agentId, sessionId, parentId?, workingDir?, accessLevel?)`
6
6
 
7
7
  **Root agent rule**: `!parentId` (parentId null/undefined = root). Does not depend on any `is_default` field.
8
8
 
@@ -17,7 +17,7 @@ Entry point: `session-manager.ts` `buildAgentInstance(agentId, sessionId, parent
17
17
  ├── prompts/ ← user-editable system prompts (externalised)
18
18
  │ ├── bootstrap/BOOTSTRAP.md ← first-run guidance
19
19
  │ ├── all/ ← every-agent rules (TOOL_GUIDELINES.md, TOOL_SHELL[.windows].md, WORKSPACE_CONVENTIONS.md, RUNTIME.md)
20
- │ └── root/ ← root-agent-only (empty by default; user-set)
20
+ │ └── root/ ← root-agent-only (DELEGATION.md, WORKSPACE_MEMORY.md)
21
21
  ├── agents/<id>/{agent.yaml, AGENT.md}
22
22
  └── skills/<id>/SKILL.md ← built-in `halo` skill carries platform self-knowledge (loaded on demand via activate_skill)
23
23
  ```
@@ -52,7 +52,7 @@ Resolution is by the agent's **folder** (`agentSourceDir`), not per file:
52
52
  agent entirely (both `agent.yaml` and `AGENT.md`), with no per-file fallback
53
53
  to global — a missing file inside it is just absent. YAML fields: `name` /
54
54
  `description` / `model` / `tools` / `skills` / `system_prompt` / `context` /
55
- `priority`.
55
+ `priority` / `internal` / `team`.
56
56
 
57
57
  ## Step 2 — Resolve MD file paths
58
58
 
@@ -127,14 +127,14 @@ Startup seeds `templates/prompts/{bootstrap,all,root}/` into `~/.halo/global/pro
127
127
 
128
128
  Replacement is **whole-file**, not per-section — so split the platform-divergent content into its own file and keep the common content in an un-suffixed file. Example in `prompts/all/`: `TOOL_GUIDELINES.md` (common) + `TOOL_SHELL.md` (unix Shell section) / `TOOL_SHELL.windows.md` (cmd.exe, `dir`/`findstr`, `%USERPROFILE%`, `python` vs `python3`, `.py` skill caveats). To add a new platform difference anywhere, extract that block to `FOO.md` and add `FOO.windows.md`.
129
129
 
130
- Result:
131
- - `prompts/bootstrap/*.md` → `bootstrapPrompt`
132
- - `prompts/all/*.md` → `allPrompt`
133
- - `prompts/root/*.md` → `rootPrompt`
130
+ Result (the returned `systemPrompts` object; the sections below write these fields as `bootstrapPrompt` / `allPrompt` / `rootPrompt`):
131
+ - `prompts/bootstrap/*.md` → `systemPrompts.bootstrap`
132
+ - `prompts/all/*.md` → `systemPrompts.all`
133
+ - `prompts/root/*.md` → `systemPrompts.root`
134
134
 
135
135
  Missing directory or read failure: warn + use built-in fallback.
136
136
 
137
- `loadSystemPrompts` also returns `files: { bootstrap: string[]; all: string[]; root: string[] }` — the absolute paths of the `.md` files actually read for each scope (empty when the built-in fallback was used). `session-manager.ts` forwards this list into `AgentMeta.mdFiles`, which `/session context` renders one line per file (label `prompt/<scope>/<basename>`). When a scope falls back to the built-in constants, a single line `prompt/<scope> (built-in fallback): <dir>` is shown instead.
137
+ `loadSystemPrompts` also returns `files: { bootstrap: string[]; all: string[]; root: string[] }` — the absolute paths of the `.md` files actually read for each scope (empty when the built-in fallback was used). `session-agent-builder.ts` forwards this list into `AgentMeta.mdFiles`, which `/session context` renders one line per file (label `prompt/<scope>/<basename>`). When a scope falls back to the built-in constants, a single line `prompt/<scope> (built-in fallback): <dir>` is shown instead.
138
138
 
139
139
  ## Step 5 — Compose the MD prompt
140
140
 
@@ -159,8 +159,8 @@ Global and workspace-root INSTRUCTIONS share the same `## User Instructions` hea
159
159
  mdPrompt ← incl. roster + working_dir scope (both inside composeMdPrompt)
160
160
  + "\n\nThe project workspace is at: {workspaceRoot}\n"
161
161
  + [optional] "Working directory: {workingDir}\n"
162
- + allPrompt
163
162
  + rootPrompt
163
+ + allPrompt
164
164
  ```
165
165
 
166
166
  (The `working_dir` directory-chain INSTRUCTIONS.md live *inside* `mdPrompt` — folded into the `## User Instructions` region by `composeMdPrompt` — not after the `Working directory:` tagline. That tagline is just a one-line focus marker.)
@@ -216,7 +216,7 @@ If `mdPrompt` is empty:
216
216
 
217
217
  ## Step 7 — Append skills and tool list
218
218
 
219
- [session-manager.ts:682-697](../../../packages/server/src/agents/session-manager.ts#L682)
219
+ [session-agent-builder.ts](../../../packages/server/src/agents/session-agent-builder.ts) `composeSystemPrompt`
220
220
 
221
221
  ### Skills (progressive disclosure)
222
222
 
@@ -245,7 +245,7 @@ AGENT.md ← workspace > global
245
245
  ## Your Team ← roster (dropped when team is empty/unset)
246
246
  ## User Instructions ← ~/.halo/global/INSTRUCTIONS.md (suppressed when ws has its own)
247
247
  ## User Instructions ← <ws>/.halo/INSTRUCTIONS.md (workspace root)
248
- ## Project Knowledge ← <ws>/.halo/INDEX.md (or nudge)
248
+ ## Project Knowledge ← <ws>/.halo/INDEX.md (skipped when absent)
249
249
  "The project workspace is at: ..."
250
250
  "Working directory: ..." ← if workingDir is set
251
251
  rootPrompt ← prompts/root/*.md (ws > global): DELEGATION.md, WORKSPACE_MEMORY.md
@@ -403,4 +403,4 @@ Schema declared by each package + values stored centrally is the same model VSCo
403
403
  - **Bootstrap trigger**: `!parentId && !userMd`
404
404
  - **System prompts are external**: resolved from workspace then global `prompts/{bootstrap,all,root}/*.md`, read live, sorted by filename.
405
405
  - **System prompt missing**: warn + use built-in fallback; `system-prompts.ts` is the seed source and fallback
406
- - **`.halo/` is not grep/globbed**: use `file_read` + INDEX.md navigation
406
+ - **`.halo/` is grep/globbed** except its runtime subtrees (`sessions/ logs/ evo/ tmp/ assets/ canvas/`)
@@ -82,9 +82,9 @@ Manages every agent session's lifecycle (root + sub-agent). Each session is 1:1
82
82
 
83
83
  | Method | Purpose |
84
84
  |---|---|
85
- | `createSession(agentId, parentId, description, agentName?, explicitId?, workingDir?, accessLevel?)` | Create a session (SQLite + memory). `workingDir` = absolute path at runtime, stored as workspace-relative in DB; null = project root. `accessLevel` = `'readonly'`, `'workspace'`, or `null` (full). |
86
- | `sendUserMessage(sessionId, text, images?)` | Send a message — run immediately if idle, queue if busy |
87
- | `compactSession(sessionId)` | LLM-summary compact |
85
+ | `createSession(agentId, parentId, description, agentName?, explicitId?, workingDir?, accessLevel?, title?)` | Create a session (SQLite + memory). `workingDir` = absolute path at runtime, stored as workspace-relative in DB; null = project root. `accessLevel` = `'readonly'`, `'workspace'`, or `null` (full). |
86
+ | `sendUserMessage(sessionId, text, images?, accessLevel?)` | Send a message — run immediately if idle, queue if busy; returns `'running'` or `'queued'` |
87
+ | `compactSession(sessionId, opts?)` | LLM-summary compact (`opts.onProgress` for UI progress) |
88
88
  | `interruptSession(sessionId)` | Abort the in-flight turn now (fire-and-forget); `interruptRequested` is set so the unwind repairs rather than errors, then the queued message drains. Shared by esc and the `interrupt_session` tool |
89
89
  | `stopSession(sessionId)` | Fold the whole `messageQueue` into `agent.messages` (preserve, don't drop), abort + repair, no re-run, sets `stoppedAt`. Cascades to descendants |
90
90
  | `deleteSession(sessionId)` | Cascade-delete a session and all descendants (SQLite) |
@@ -196,7 +196,7 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
196
196
 
197
197
  **Arrival stamp.** Both paths run the turn through `runAgentTurn`, which prepends `[<ISO-8601 UTC>] ` (e.g. `[2026-09-12T15:17:44.153Z] `) to the first text block of the model-facing input — an image-only input gets a stamp-only text block unshifted. The model has no clock; without this it couldn't tell a reply that came two days later from one that came instantly, or how long a sub-agent report took to land. Stamped **once, before the retry loop**, so every attempt (and the [multimodal 4xx degrade](#multimodal-4xx-degrade) rebuild) reuses the identical input. Agent reports therefore read `[<iso>] (from: session X)\n…` — stamp outermost, no special-casing. Only `agent.messages` changes: the UI `type:'user'` event keeps the raw text (see [Turn matching](#exchange-deletion-soft-ui--hard-raw) for the strip on compare), and the self-compact instruction / `[Conversation Summary]` injection / `tool_result` messages are never stamped.
198
198
 
199
- **Final-text flag.** The agent loop marks each text block `final` when the model call ended for a reason other than `tool_use` (i.e. it is the turn's wrap-up, not the filler emitted before a tool call), and `runAgentTurn`'s `case 'text'` forwards it on the `stream` event as `AgentSessionEvent.final`. Block-send consumers gate on it: the channel responders (wechat / telegram / slack / feishu) append only `final` text, and `halo cli` prints the last root turn's `final` text as its stdout (fallback: that turn's full text). `finalOutput` (used by `tryReportToParent`) is the same accumulation on the server side. Streaming providers additionally yield `text_delta` / `thinking_delta` events during the model call (forwarded as `stream_delta` / `thinking_delta`, UI-only); they never touch `output` / `finalOutput` — those only ever see the whole `text` event, which follows every completed call exactly as before. The one place the loop itself keeps the text deltas is the interrupted-stream landing (see [Partial streamed text lands](#conversation-repair)): a call cancelled mid-stream pushes what it had streamed as a marked assistant turn so the model's history matches what the UI showed.
199
+ **Final-text flag.** The agent loop marks each text block `final` when the model call ended for a reason other than `tool_use` (i.e. it is the turn's wrap-up, not the filler emitted before a tool call), and `runAgentTurn`'s `case 'text'` forwards it on the `stream` event as `AgentSessionEvent.final`. Block-send consumers gate on it: the channel responders (wechat / telegram / slack / feishu / wecom) append only `final` text, and `halo cli` prints the last root turn's `final` text as its stdout (fallback: that turn's full text). `finalOutput` (used by `tryReportToParent`) is the same accumulation on the server side. Streaming providers additionally yield `text_delta` / `thinking_delta` events during the model call (forwarded as `stream_delta` / `thinking_delta`, UI-only); they never touch `output` / `finalOutput` — those only ever see the whole `text` event, which follows every completed call exactly as before. The one place the loop itself keeps the text deltas is the interrupted-stream landing (see [Partial streamed text lands](#conversation-repair)): a call cancelled mid-stream pushes what it had streamed as a marked assistant turn so the model's history matches what the UI showed.
200
200
 
201
201
  **drainQueue** folds the **whole queue** into ONE merged follow-up turn per round, re-checking after each round (a fresh interrupt or a sibling's report can land mid-drain):
202
202
  - The batch is `splice(0)`'d; agent entries keep a `(from: session X)` prefix, user entries fold raw, and all entries' `images` are merged in.
@@ -205,7 +205,7 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
205
205
 
206
206
  **`complete` invariants** (root only):
207
207
  - The **terminal** `complete` is emitted from `runSession`'s `finally`, and `promise = null` is set **before** emitting it — the CLI / web stream-close logic gates on `complete && !hasRunningSessions()`, which reads `promise !== null`; emitting `complete` first would leave the session still "running" at the moment the client decides whether to close.
208
- - **`batchBoundary` complete**: when a merged round finishes **and the queue still has a next round**, `drainQueue` emits `{ type: 'complete', batchBoundary: true }`. This is a per-round flush signal for **block-oriented channels** (wechat / telegram / slack / feishu), which buffer streamed text and only ship a message on `complete` — without it, N drain rounds buffer into one blob that lands only at the terminal `complete` ("8 reports in one lump"). **Stream-terminating consumers (web-channel SSE, ACP) must ignore `batchBoundary` and keep the stream open** — the root is still running and more output follows; only the terminal (unmarked) `complete` closes the stream. See [web.md](web.md) and the per-channel coalescing notes (e.g. [wechat.md](wechat.md)). The field is **not** carried into the WS protocol — admin closes its bubble on `chat:complete` either way.
208
+ - **`batchBoundary` complete**: when a merged round finishes **and the queue still has a next round**, `drainQueue` emits `{ type: 'complete', batchBoundary: true }`. This is a per-round flush signal for **block-oriented channels** (wechat / telegram / slack / feishu / wecom), which buffer streamed text and only ship a message on `complete` — without it, N drain rounds buffer into one blob that lands only at the terminal `complete` ("8 reports in one lump"). **Stream-terminating consumers (web-channel SSE, ACP) must ignore `batchBoundary` and keep the stream open** — the root is still running and more output follows; only the terminal (unmarked) `complete` closes the stream. See [web.md](web.md) and the per-channel coalescing notes (e.g. [wechat.md](wechat.md)). The WS `chat:complete` frame carries the field too — admin closes its bubble either way but rings the finish bell only on the terminal one.
209
209
 
210
210
  **Three-tier interrupt model** — two **soft** paths and one **hard** path, distinguished by *when* the abort fires, never by whether the message is kept (all three preserve the queue):
211
211
  1. **`query_session` while busy — soft interrupt.** `querySession(..., interrupt=false)` enqueues **and** sets `interruptRequested` (same as a user message). This is what makes a sub-agent fold two queued questions into **one merged answer** instead of replying one-by-one: the in-flight turn unwinds after its current tool, then `drainQueue`'s `splice(0)` folds every message that landed alongside it into a single round — matching how root handles two user messages. An **idle** target has no turn to interrupt, so it just runs the message directly.
@@ -229,7 +229,7 @@ A hard interrupt aborts the turn's `AbortSignal`, which `agent-loop.ts` forwards
229
229
 
230
230
  - **`full` access (non-Windows)** spawns via `spawn(command, { shell: true, detached: true })` so the command becomes a **process-group leader** (`pgid === child.pid`). On abort *or* timeout, `process.kill(-pid, 'SIGTERM')` signals the **whole group**, so a compound command's worker dies with the shell.
231
231
  - **Why not `execAsync(command, { signal })`** (the previous implementation): `exec` wraps the command in `/bin/sh -c "<command>"` and, on abort, only SIGTERMs that `sh`. A compound command (`sleep 60 && …`) has already forked the real worker (`sleep`) as a child of `sh` — the signal never reaches it, so it **reparents to init (PPID 1) and runs to completion as an orphan**. The agent turn unwinds correctly (the promise rejects), but the command keeps running, which made `interrupt_session` *look* like it didn't really interrupt. Single non-compound commands didn't expose it (Node optimizes them to a direct `exec` with no `sh` layer).
232
- - **`workspace` / `readonly` access** run under `bwrap` with `--die-with-parent`, a kernel-level guarantee that the sandboxed child dies with its parent — that path was never affected and is unchanged. **Windows** `full` keeps the `execAsync` path (no process groups; the orphan case doesn't arise the same way).
232
+ - **`workspace` / `readonly` access** run under `bwrap` with `--die-with-parent`, a kernel-level guarantee that the sandboxed child dies with its parent — that path was never affected and is unchanged. On macOS they run under Seatbelt (`sandbox-exec`) through the same `spawnGroupExec` group kill. **Windows** `full` keeps the `execAsync` path (no process groups; the orphan case doesn't arise the same way).
233
233
 
234
234
  The `spawnGroupExec` helper preserves `promisify(exec)`'s contract exactly: resolve `{ stdout, stderr }` on exit 0; reject with an `Error` carrying `.message` / `.stdout` / `.stderr` / `.code` otherwise (abort rejects with `name: 'AbortError'`). Covered by `test/sandbox-process-group.test.ts`, which asserts by side effect — a sentinel file the orphaned worker *would* create must never appear after the abort.
235
235
 
@@ -375,7 +375,7 @@ Frontend network issues don't affect the backend:
375
375
  | **4xx multimodal rejection** (`Multimodal data is corrupted` / `Could not process image`) | Replace all image blocks in history with text placeholders, persist, retry — see [Multimodal 4xx degrade](#multimodal-4xx-degrade) below |
376
376
  | Unrecoverable error | Report to user, stop |
377
377
 
378
- **Refusal stop.** `stop_reason: "refusal"` (Anthropic models on Bedrock invoke + generic anthropic) is an HTTP 200 with `stop_details {category, explanation}` — not an error, so it never enters the retry matrix above. `agent-loop.ts:208` handles it explicitly: no assistant message is pushed, any partial `tool_use` is **not** executed, the loop ends; `session-manager.ts:1226` emits a `system` event `⚠️ [<agent>] Model declined to respond (<category>): <explanation>` suggesting `/new`. Previously it fell through the `end_turn` path and looked like a silent stall.
378
+ **Refusal stop.** `stop_reason: "refusal"` (Anthropic models on Bedrock invoke + generic anthropic) is an HTTP 200 with `stop_details {category, explanation}` — not an error, so it never enters the retry matrix above. `agent-loop.ts` handles it explicitly: no assistant message is pushed, any partial `tool_use` is **not** executed, the loop ends; `session-manager.ts` emits a `system` event `⚠️ [<agent>] Model declined to respond (<category>): <explanation>` suggesting `/new`. Previously it fell through the `end_turn` path and looked like a silent stall.
379
379
 
380
380
  **Classification order: structured signal first, message text last.** The interrupt branch is decided by the attempt's own `AbortController` signal (every interrupt path we own goes through `abortReason()` on it), never by the words `cancelled` / `aborted` in the message — an upstream error body can legitimately read `"The operation was aborted due to …"` (Bedrock), and matching that swallowed a real failure as a user interrupt: silent `break`, no retry, no error event, an empty reply. Likewise the account-level branch trusts `httpStatus` when one was recovered: 401/402/403 is terminal, any *other* status is not, whatever the body says — an OpenAI-compatible 503 whose body reads `"authentication service temporarily unavailable"` used to be classified as a dead key and never retried. The keyword list is only consulted when no status could be extracted. Pinned by `turn-retry-idempotent.test.ts` (five classification cases).
381
381
 
@@ -401,7 +401,7 @@ The transient-5xx branch is the one that all providers share, and getting the **
401
401
 
402
402
  **Retry decision** — a failure is treated as a transient server-side error (→ retry with backoff) when **either**:
403
403
 
404
- - `err.name` is `InternalServerException`, `ModelTimeoutException`, or `ServiceUnavailableException`; **or**
404
+ - `err.name` is `InternalServerException`, `ModelTimeoutException`, `ModelStreamErrorException`, or `ServiceUnavailableException`; **or**
405
405
  - the extracted `httpStatus` is one of `500` / `502` / `503` / `504` / `529` (Anthropic "Overloaded") / `408` (request/model timeout).
406
406
 
407
407
  Throttling is checked the same way one branch earlier — structured `ThrottlingException` name or status 429 first, message keywords second; the real Bedrock throttle message ("Too many requests, please wait before trying again.") carries no keyword, so the name/status check is what catches it.
@@ -418,7 +418,7 @@ Three entry points with different quality / safety trade-offs:
418
418
  |---|---|---|---|
419
419
  | 90% soft threshold (mid-turn auto) | `maybeAutoCompact()` via agent-loop's `beforeCallModel` hook — runs before each model call within a turn | **Self-compact** (`selfCompactSession`) — the agent summarizes its own context, then a tail micro-compact pass clears bulk tool output. **Not interruptible, no wall-clock cap; local fallback** if the summary throws, idle-times-out or comes back empty (see below) | The agent already has full context cached (prompt cache hit). No extra model call, no input duplication, no risk of losing tool_result semantics. Firing mid-turn (not just at turn end) stops a single long turn that accumulates many large tool results from blowing the window. |
420
420
  | Overflow mid-loop (`too many input tokens`, `Input is too long`, …) | `runAgentTurn` retry catch → `localCompactMessages` → retry | **Local** — `[role]: <first N chars>` concat, no network call | The model just refused this payload; an LLM round-trip now could stall the recovery path. Local is deterministic and instant; the next end-of-turn can re-summarize via self-compact. |
421
- | User `/session compact` (web, WeChat) | `commands/compact.ts` / `SessionManager.compactSession` | **Self-compact**, cancellable, **no local fallback** | User explicitly requested it; self-compact reuses the cached context so it's fast. A failure (model error / idle timeout) leaves the context unchanged (`Compaction failed — context unchanged`) and is rethrown to the caller; an empty summary emits `Compaction skipped — no summary produced`. |
421
+ | User `/session compact` (web, WeChat) | `channels/shared/commands.ts` (admin WS: `ws/handler.ts`) / `SessionManager.compactSession` | **Self-compact**, cancellable, **no local fallback** | User explicitly requested it; self-compact reuses the cached context so it's fast. A failure (model error / idle timeout) leaves the context unchanged (`Compaction failed — context unchanged`) and is rethrown to the caller; an empty summary emits `Compaction skipped — no summary produced`. |
422
422
 
423
423
  Self-compact **deep-snapshots the keep-region before running the summarize turn** (`messages.slice(cut).map(structuredClone)`), then injects a summarization instruction into the agent's own stream, captures the response, and rebuilds messages as `[summary + snapshot]`. This reuses the provider's prompt cache (no separate model needed) and preserves full semantic context including tool results.
424
424
 
@@ -426,7 +426,7 @@ The pre-run snapshot is load-bearing, not an optimization: `agent.run()` coalesc
426
426
 
427
427
  As a final byte-trimming step, self-compact runs **micro-compact** over that snapshot (`microCompactMessages(cleanRecent, 1)`). Each kept-recent message may still carry a large tool result (e.g. a 50 KB `file_read`), so after summarizing it would otherwise re-cross the threshold immediately. Micro keeps only the newest `tool_result`'s content and clears the rest in place, preserving `tool_use_id` pairing so the next API call stays valid — no extra LLM round-trip. Micro-compact is **not** a standalone compaction path; `selfCompactSession` is its only call site — self-compact is the entry point, micro is its tail cleanup.
428
428
 
429
- All paths share the same split logic — `compactCut(messages)`, exported from `agents/compact.ts` (single source of truth for SessionManager — `selfCompactSession` + the two feasibility gates below — and `localCompactMessages`): keep the last `keepMessages` turns, advance the cut forward past any orphan `tool_result`-first user message (otherwise the next API call gets `unexpected tool_use_id`). The tail loop only ever moves the cut *up*, so `cut === 0` is exactly the `messages.length <= keepMessages` "nothing to compact" case.
429
+ All paths share the same split logic — `compactCut(messages)`, exported from `agents/compact.ts` (single source of truth for SessionManager — `selfCompactSession` + the two feasibility gates below — and `localCompactMessages`): keep the last `keep_messages` turns, advance the cut forward past any orphan `tool_result`-first user message (otherwise the next API call gets `unexpected tool_use_id`). The tail loop only ever moves the cut *up*, so `cut === 0` is exactly the `messages.length <= keep_messages` "nothing to compact" case.
430
430
 
431
431
  **Bounds and Stop.** Self-compact has no wall-clock timeout: a long but still-streaming summary is fine, and a hung call is bounded only by the model-call idle timeout (`timeout.model_request`, re-armed on any data the stream delivers; expiry throws `MODEL_TIMEOUT_ERROR`). An **auto-compact is not interruptible** — `maybeAutoCompact` passes no signal. Stop / Esc (`stopSession`, `interruptSession`, `stopUserSession`, WS `chat:stop` / `chat:interrupt`, `/api/web/stop`) abort only the turn's own controller, which the loop checks once `beforeCallModel` returns, so they take effect after the compact finishes (the stop request itself waits until then). Messages arriving meanwhile queue and run as the next turn once the compact ends; a Stop that folds the queue into history during the compact parks the text in `foldAfterCompact`, and `clearCompacting` lands it on the post-compact history (`foldIntoAgentMessages`). A **manual `/compact`** has no turn, so `cancelCompact` (WS `chat:stop` / `chat:interrupt`, channel `/stop` / `/interrupt` and `POST /api/web/stop` while compacting with no turn running, and `stopUserSession`) cancels it: history rolls back and `Compact cancelled` is emitted.
432
432
 
@@ -438,7 +438,7 @@ All paths share the same split logic — `compactCut(messages)`, exported from `
438
438
 
439
439
  **Local compact output is API-legal on its own.** `localCompactMessages` returns `[summary, ...recent]` where `recent` starts past any `tool_result`-first user message, so every kept `tool_result` has its `tool_use` in the kept tail and `repairConversationMessages` is a no-op on the result (also on a second pass over its own output). Pinned by `local-compact-roundtrip.test.ts` — without it the overflow retry would only have "worked" via the later corrupted-conversation repair retry.
440
440
 
441
- Config (see `config.compact`): `keepMessages` / `maxSummaryInput` / `maxMessageSlice` — editable in Settings → General → compact.
441
+ Config (see `config.compact`): `keep_messages` / `max_summary_input` / `max_message_slice` (settings keys `general.compact.*`) — editable in Settings → General → compact.
442
442
 
443
443
  **Self-compact also archives the UI log.** `selfCompactSession` calls `uiStore.archiveOldMessages(session.id)` for **root and sub-sessions alike** (root-only until 1.5.3-alpha; a long-running sub-agent's UI log then grew without bound — 4.77 MB in testing), right before the compaction notice is emitted so the notice lands in the kept exchange rather than inside the archived segment. Compaction is the one path that already means "history shrinks here", which is why archiving hangs off it rather than off a timer or its own sweep — the size threshold below decides whether the call does anything. See [UI-log archiving](#ui-log-archiving).
444
444
 
@@ -464,7 +464,7 @@ Files: `packages/server/src/sessions/session-archive.ts` (segment IO) · `agents
464
464
 
465
465
  **Trigger: file size.** A compact archives only when the active `<seg>.json` is larger than `ARCHIVE_SIZE_THRESHOLD = 3 MB` (one `statSync`; a missing file counts as 0, so the check is also the "nothing written yet" guard). Below it, `archiveOldMessages` returns 0 and touches nothing, however many exchanges have piled up.
466
466
 
467
- Bytes, not an exchange count, because bytes are the actual problem — file size is what makes a log slow to read, parse and ship — and per-exchange size varies by ~three orders of magnitude (a bare "yes" versus a turn carrying several 50 KB tool results), so a count is a badly distorted proxy: the same "15 exchanges" is 40 KB in one session and 40 MB in another. It is also self-limiting: after an archive the file restarts near-empty, so it must grow another 3 MB of real content before the next segment, which bounds segment count by total bytes written rather than by how often the user compacts. Still no relation to `config.compact.keepMessages` (that counts raw LLM messages — a different granularity with no honest mapping to UI rows), and no setting: one constant, tuned once.
467
+ Bytes, not an exchange count, because bytes are the actual problem — file size is what makes a log slow to read, parse and ship — and per-exchange size varies by ~three orders of magnitude (a bare "yes" versus a turn carrying several 50 KB tool results), so a count is a badly distorted proxy: the same "15 exchanges" is 40 KB in one session and 40 MB in another. It is also self-limiting: after an archive the file restarts near-empty, so it must grow another 3 MB of real content before the next segment, which bounds segment count by total bytes written rather than by how often the user compacts. Still no relation to `config.compact.keep_messages` (that counts raw LLM messages — a different granularity with no honest mapping to UI rows), and no setting: one constant, tuned once.
468
468
 
469
469
  **Split point.** Over the threshold, `archiveSplitIndex(messages, 1)` keeps only the **newest main user exchange** (`role === 'user'` without `taskId` — the same subset the admin renders and `deleteExchange` counts ordinals over) and cuts at its start, so a turn's responses are never split from their user message. A log holding a single exchange yields cut 0 and is left alone: there is nothing to move without splitting the turn the user is looking at, so an oversized *single* exchange is deliberately not archived.
470
470
 
@@ -61,7 +61,7 @@ Files: `packages/server/src/channels/slack/`
61
61
  - `types.ts` — SlackAccount, SlackSocketEnvelope, SlackMessageEvent interfaces
62
62
  - `accounts.ts` — DAL (thin wrapper over `channels/shared/accounts.ts` for Slack-specific mappings)
63
63
  - `handler.ts` — Socket Mode connection + event routing, mention filtering; session resolution + per-thread listener/route bookkeeping come from `channels/shared/inbound.ts` (`InboundBridge` / `deliverInbound` / `dispatchChannelCommand`)
64
- - `event-adapter.ts` — AgentSessionEvent → Slack messages (buffer + flush; split at 35k chars)
64
+ - `event-adapter.ts` — AgentSessionEvent → Slack messages (buffer + flush on `complete`; split at 35k chars)
65
65
  - `api.ts` — HTTP client (auth.test / openSocketModeConnection / chat.postMessage / files.upload / files.download / searchSlackTargets)
66
66
  - `cron-dispatcher.ts` — CronDispatcher registration; requires an explicit Slack chatId per dispatch
67
67
 
@@ -109,13 +109,13 @@ Unlike Telegram (grammy polling) or WeChat (HTTP long-poll), Socket Mode elimina
109
109
 
110
110
  1. SessionManager emits AgentSessionEvent stream chunks
111
111
  2. SlackResponder buffers only chunks flagged `final` (the turn's wrap-up text; pre-tool-call filler is dropped) until `complete` event
112
- 3. On `complete` or when buffer hits 35k chars:
113
- - Split at paragraph boundary if needed via the shared `splitText` (`channels/shared/chunk.ts`; Slack hard-caps messages at ~40k)
112
+ 3. On `complete` (Slack doesn't set `splitMidStream`, so the buffer is never flushed early at the limit):
113
+ - Split at the 35k-char limit, at a paragraph boundary if possible, via the shared `splitText` (`channels/shared/chunk.ts`; Slack hard-caps messages at ~40k)
114
114
  - Extract `MEDIA: <path>` markers (sent as native file uploads)
115
115
  - Convert CommonMark → Slack's mrkdwn format
116
116
  - Post as a single message (or series of messages if split)
117
117
  - This fires on **any** `complete`; the responder ignores its `batchBoundary` flag, so a multi-round queue drain posts each merged turn as its own message rather than one blob (see [session.md](session.md#message-queue-and-drain))
118
- 4. Errors and system notices flush immediately (don't wait for complete)
118
+ 4. Errors (`❌ …`) and system notices (`ℹ️ …`) flush immediately (don't wait for complete)
119
119
  5. Sub-agent events (taskId set) are dropped (visible in web UI only)
120
120
 
121
121
  **Chunk sends are serialized per responder.** One `flushBuffer` can produce
@@ -125,7 +125,9 @@ previous one, so Slack could accept them out of order and a long reply arrived
125
125
  scrambled (audit A-L3). Each responder now keeps a `sendTail` promise chain and
126
126
  appends every chunk to it, so chunk *n+1* only goes out after *n* settles; a
127
127
  rejected link is absorbed (`dispatchChunk` logs its own send failures) so one
128
- bad send can't stall the rest of the reply.
128
+ bad send can't stall the rest of the reply. The buffer + chain live in the
129
+ shared `ChunkedResponder` (`channels/shared/responder.ts`), which slack / feishu
130
+ / wecom / wechat / telegram extend with their own limit and formatter.
129
131
 
130
132
  `close()` therefore returns that drain promise, and `InboundBridge` keeps the
131
133
  session's reply route registered until it settles before deleting it — including
@@ -138,20 +140,20 @@ dropped.
138
140
 
139
141
  ### DM sessions
140
142
 
141
- DM channels have a stable channel_id (`D…`). A message in a DM stays in the same session regardless of turn count. Session key: `slack:${channelId}:dm`.
143
+ DM channels have a stable channel_id (`D…`). A message in a DM stays in the same session regardless of turn count. Session prefix: `slack_${channelId}:dm_`.
142
144
 
143
145
  ### Channel / group / thread sessions
144
146
 
145
- A mention on a top-level message starts a thread and creates a session keyed by that message's `ts`. Replies inside that thread reuse the same `thread_ts`, so they map to the same session and preserve context. Session key: `slack:${channelId}:${rootTs}`.
147
+ A mention on a top-level message starts a thread and creates a session keyed by that message's `ts`. Replies inside that thread reuse the same `thread_ts`, so they map to the same session and preserve context. Session prefix: `slack_${channelId}:${rootTs}_`.
146
148
 
147
149
  ### Session resolution
148
150
 
149
- The `getOrCreateSessionForThread()` helper:
150
- 1. Build session prefix: `slack:${channelId}:${rootTs}:`
151
- 2. Check per-user activeOverrides (set by `/session switch` command in DMs)
152
- 3. Look for an existing session matching the prefix + userId
153
- 4. If none found, create a new session with ID `${prefix}${Date.now().toString(36)}`
154
- 5. Store in activeOverrides for future messages from this user
151
+ `handleInbound()` hands the message to `deliverInbound` (`channels/shared/inbound.ts`), which calls `getOrCreateChannelSession`:
152
+ 1. Build the session prefix with `buildSessionPrefixForThread(channelId, rootTs)` (see above; `rootTs` is `dm` for DMs); the override key is `${userId}@${channelId}:${rootTs}`
153
+ 2. `findActiveSessionId` (`channels/shared/commands.ts`): honour the per-user activeOverrides entry (set by `!session switch` in DMs), else the latest session under the prefix
154
+ 3. If none found, create one with ID `${prefix}${Date.now().toString(36)}` on the workspace's default agent and store it in activeOverrides (`setOverrideOnCreate`)
155
+
156
+ **Reply route after a restart.** Routes and listeners are in-memory, so `startAccount` calls `restoreReplyRoute()`: it reads `lastActiveChatId` (`<channelId>:<rootTs>`) and re-wires the latest existing session under that prefix via `restoreChannelRoute` (no session is created), so a turn that resumes on its own after a restart still reaches Slack. Only that one conversation per account is restored; other threads re-wire on their next inbound message.
155
157
 
156
158
  ## Cron-dispatcher role
157
159
 
@@ -203,7 +205,7 @@ Text, images (base64 attached to agent input for vision), files. All files are d
203
205
 
204
206
  The agent runtime produces `MEDIA: <path>` markers. SlackResponder:
205
207
  - Intercepts the markers and calls `uploadFile()` for each one
206
- - Blocks sandbox violation for non-`full` accounts (files must be under workspace or /tmp — `isMediaPathAllowed(path, workspace, accessLevel)`; `full` accounts may send any readable path). A block throws inside the same try as the upload, so it surfaces as the `handler.upload_failed` text below
208
+ - Blocks sandbox violation for non-`full` accounts (files must be under workspace or /tmp — `isMediaPathAllowed(path, workspace, accessLevel)`; `full` accounts may send any readable path). A block throws inside the same try as the upload (`sendMediaOrReport` in `channels/shared/media.ts`), so it surfaces as the `handler.upload_failed` text below
207
209
  - Falls back to error text if upload fails
208
210
 
209
211
  Outbound media uses Slack's v2 upload flow (getUploadURLExternal → signed PUT → completeUploadExternal).
@@ -218,9 +220,9 @@ Not supported: interactive messages (blocks/buttons/callbacks), slash commands i
218
220
 
219
221
  Slack-specific paths:
220
222
 
221
- - `packages/server/src/channels/slack/handler.ts:252` — `connect()` Socket Mode setup
222
- - `packages/server/src/channels/slack/handler.ts:391` — `handleInbound()` event dispatch
223
- - `packages/server/src/channels/slack/handler.ts:552` — `getOrCreateSessionForThread()` session keying
224
- - `packages/server/src/channels/slack/api.ts:265` — `openSocketModeConnection()` fetch wss:// URL
225
- - `packages/server/src/channels/slack/cron-dispatcher.ts:46` — `dispatch()` cron push logic
226
- - `packages/server/src/channels/slack/event-adapter.ts:90` — `dispatchChunk()` buffer flush + media extraction
223
+ - `packages/server/src/channels/slack/handler.ts` `connect()` (inside `startSlackChannel()`) — Socket Mode setup
224
+ - `packages/server/src/channels/slack/handler.ts` `handleInbound()` — event dispatch
225
+ - `packages/server/src/channels/slack/handler.ts` `buildSessionPrefixForThread()` + `packages/server/src/channels/shared/inbound.ts` `deliverInbound()` / `getOrCreateChannelSession()` — session keying + resolution
226
+ - `packages/server/src/channels/slack/api.ts` `openSocketModeConnection()` — fetch wss:// URL
227
+ - `packages/server/src/channels/slack/cron-dispatcher.ts` `dispatch()` — cron push logic
228
+ - `packages/server/src/channels/shared/responder.ts` — `ChunkedResponder.dispatchChunk()` buffer flush + media extraction (`slack/event-adapter.ts` supplies the 35k limit + `formatForSlack`)
@@ -11,8 +11,8 @@ Defines the persisted-data format for every Halo surface. Format changes must re
11
11
  │ ├── USER.md # User profile (bootstrap-generated)
12
12
  │ ├── prompts/ # User-editable system prompts (externalised)
13
13
  │ │ ├── bootstrap/BOOTSTRAP.md # First-run guidance
14
- │ │ ├── all/ # Every agent (TOOL_GUIDELINES.md, TOOL_SHELL[.windows].md)
15
- │ │ └── root/ # Root agent only — empty by default; user-set
14
+ │ │ ├── all/ # Every agent (TOOL_GUIDELINES.md, TOOL_SHELL[.windows].md, WORKSPACE_CONVENTIONS.md, RUNTIME.md)
15
+ │ │ └── root/ # Root agent only (DELEGATION.md, WORKSPACE_MEMORY.md)
16
16
  │ ├── agents/<id>/
17
17
  │ │ ├── agent.yaml # Agent config
18
18
  │ │ └── AGENT.md # Agent personality
@@ -26,7 +26,8 @@ Defines the persisted-data format for every Halo surface. Format changes must re
26
26
  │ ├── evo.db # Cross-workspace evolution queue (evolution_runs + evolution_applies)
27
27
  │ ├── cron.db # Cross-workspace cron jobs + run history (cron_jobs + cron_runs)
28
28
  │ ├── runs.db # Run ledger: (workspace, session_id) rows for server-driven runs in flight; steady state empty (running_sessions)
29
- │ ├── logs/ # Runtime logs
29
+ │ ├── extensions/<id>/ # Installed Canvas preview extensions (halo-extension.json + bundle)
30
+ │ ├── logs/ # Runtime logs (server.log)
30
31
  │ │ ├── evo/ # Per-evo-run wrapper logs
31
32
  │ │ └── cron/ # Per-cron-run cli stdout/stderr (30-day retention)
32
33
  │ ├── server.lock # Single-instance pid lock
@@ -62,7 +63,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
62
63
  ├── tmp/ # Agent scratch files (logs, downloads, intermediate artifacts) — convention from TOOL_GUIDELINES
63
64
  ├── assets/<channel>/inbound/<accountId>/<date>/ # Inbound media per channel (image/voice/video/file)
64
65
  ├── runtime.lock # Workspace runtime ownership marker (pid) — see below
65
- ├── halo.db # Per-workspace sqlite (sessions metadata, command registry, disabled-items)
66
+ ├── halo.db # Per-workspace sqlite (sessions metadata, disabled-items)
66
67
  └── docs/ # Project docs (requirements/design/dev/test/plans)
67
68
  ```
68
69
 
@@ -148,11 +149,11 @@ type MessageType =
148
149
  interface SessionMessage {
149
150
  // Required
150
151
  id: string // "m_{timestamp36}_{counter36}"
151
- type: MessageType
152
+ type?: MessageType // optional for backward compat (inferMessageType)
152
153
  role: 'user' | 'assistant' | 'system'
153
154
  content: string
154
155
  timestamp: number // Unix ms
155
- agentName: string
156
+ agentName?: string
156
157
 
157
158
  // Optional scope
158
159
  taskId?: string // Sub-agent task ID
@@ -181,6 +182,9 @@ interface SessionMessage {
181
182
 
182
183
  // Transient (not persisted)
183
184
  streaming?: boolean
185
+
186
+ // Soft delete (deleteExchange) — greyed out in the UI, removed from raw context
187
+ deleted?: boolean
184
188
  }
185
189
  ```
186
190
 
@@ -287,12 +291,15 @@ interface AgentYamlConfig {
287
291
  maxTokens?: number
288
292
  promptCaching?: boolean | string
289
293
  thinking?: { enabled?: boolean; budget?: string; effort?: string }
294
+ verbosity?: string
290
295
  }
291
296
  system_prompt?: string
292
297
  tools?: string[]
293
298
  skills?: string[]
294
299
  context?: { maxTokens?: number; compressAt?: number }
295
300
  priority?: number
301
+ internal?: boolean // platform tooling — off the roster, no workspace context
302
+ team?: string[] // delegation whitelist; non-empty = can delegate
296
303
  }
297
304
  ```
298
305
 
@@ -335,7 +342,7 @@ general: # built-in declarer (the server itself
335
342
  max_summary_input: 15000
336
343
  max_message_slice: 800
337
344
  sandbox:
338
- hidden_dirs: "~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh"
345
+ hidden_dirs: "~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs"
339
346
  hidden_files: "~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/evo.db,~/.halo/global/evo.db-wal,~/.halo/global/evo.db-shm,~/.halo/global/cron.db,~/.halo/global/cron.db-wal,~/.halo/global/cron.db-shm,~/.halo/global/runs.db,~/.halo/global/runs.db-wal,~/.halo/global/runs.db-shm"
340
347
  logging:
341
348
  level: warn
@@ -388,7 +395,6 @@ A value of the form `<<ENV_NAME>>` is replaced at read time with `process.env.EN
388
395
  | `general.limits.grep_default_matches` | 50 | Default `grep` match cap when no explicit `max` is passed |
389
396
  | `general.limits.tool_result_render_chars` | 8000 | Per-tool-result cap on the content **fed to the LLM** (truncated with a re-run marker to protect the context window / prompt cache). The UI gets a much larger slice — see `tool_result_ui_chars` and `agent-loop.ts` (`resultContent` = LLM cap, `resultTextFull` = UI cap) |
390
397
  | `general.limits.tool_result_ui_chars` | 65536 | Per-tool-result cap on the content **stored for UI display** (admin/web chat panel). Far larger than the LLM cap so a normal command's full output stays visible, but bounded so a multi-MB `cat` can't bloat the session file / WS payload / browser render; excess truncated with a marker pointing at `file_read` |
391
- | `general.limits.ws_event_buffer` | 5000 | Events buffered per detached WS session before oldest are dropped on reattach |
392
398
  | `general.limits.terminal_scrollback_bytes` | 50000 | Off-screen scrollback bytes retained per detached persistent terminal |
393
399
  | `general.sandbox.hidden_dirs` | `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs` | Hidden from workspace/readonly sessions — bwrap tmpfs overlay (Linux) / Seatbelt deny (macOS) |
394
400
  | `general.sandbox.hidden_files` | `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/evo.db,~/.halo/global/evo.db-wal,~/.halo/global/evo.db-shm,~/.halo/global/cron.db,~/.halo/global/cron.db-wal,~/.halo/global/cron.db-shm,~/.halo/global/runs.db,~/.halo/global/runs.db-wal,~/.halo/global/runs.db-shm` | Hidden from workspace/readonly sessions — bwrap bind of the empty `~/.halo/.sandbox-empty`, reads as empty (Linux) / Seatbelt deny (macOS) |
@@ -536,6 +542,8 @@ interface SkillMeta {
536
542
  name: string // from frontmatter
537
543
  description: string // from frontmatter
538
544
  path: string // absolute path to SKILL.md
545
+ command?: string // from frontmatter
546
+ requiresAccess?: 'full' | 'workspace' | 'readonly' // from frontmatter
539
547
  }
540
548
  ```
541
549
 
@@ -552,16 +560,6 @@ The databases hold session metadata indexes and workspace-scoped preferences (e.
552
560
 
553
561
  ### Tables
554
562
 
555
- **`sessions`** — frontend session index (created on first chat)
556
-
557
- | Column | Type | Notes |
558
- |---|---|---|
559
- | id | TEXT PK | Session ID |
560
- | title | TEXT NOT NULL DEFAULT '' | Truncated first user message |
561
- | messages | TEXT | Legacy field, unused (content now in JSON) |
562
- | message_count | INTEGER | Message count |
563
- | created_at / updated_at | INTEGER | Unix ms |
564
-
565
563
  **`agent_sessions`** — agent session index (root + sub-sessions)
566
564
 
567
565
  | Column | Type | Notes |
@@ -602,7 +600,7 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
602
600
  | Column | Type | Notes |
603
601
  |---|---|---|
604
602
  | account_id | TEXT PK | Channel-specific ID (e.g. `halo_agent_bot`, `abc-im-bot`, `e718bb7b`) |
605
- | channel_type | TEXT NOT NULL | `'telegram'`, `'web'`, `'wechat'`, `'slack'`, or `'feishu'` |
603
+ | channel_type | TEXT NOT NULL | `'telegram'`, `'web'`, `'wechat'`, `'slack'`, `'feishu'`, or `'wecom'` |
606
604
  | workspace_path | TEXT NOT NULL | Absolute path of the bound workspace |
607
605
  | label | TEXT | User-chosen name |
608
606
  | enabled | INTEGER | 1 = active, 0 = disabled |
@@ -620,6 +618,7 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
620
618
  | wechat | `botToken`, `baseUrl`, `userId`, `syncBuf` |
621
619
  | slack | `botToken`, `appToken`, `botUserId`, `teamId` |
622
620
  | feishu | `appId`, `appSecret`, `verificationToken`, `encryptKey`, `botOpenId` |
621
+ | wecom | `botId`, `secret` |
623
622
 
624
623
  ### Schema change rules
625
624
 
@@ -17,11 +17,11 @@ All channels are peers — each one is a subscriber + caller against SessionMana
17
17
 
18
18
  ## Shared inbound skeleton
19
19
 
20
- All four IM channels (telegram / wechat / slack / feishu) route inbound messages through `channels/shared/inbound.ts` rather than each keeping its own copy of the same ~200-line tail. The copies had drifted independently, which is how two audited bugs happened (audit A, 2026-08-06): the wechat responder captured its recipient at listener-registration time (A-M2), and all four command-dispatch sites ignored `CommandResult.startedTurn`, so a skill command on a fresh session kicked the agent with no listener attached and the reply vanished (A-M5).
20
+ All five IM channels (telegram / wechat / slack / feishu / wecom) route inbound messages through `channels/shared/inbound.ts` rather than each keeping its own copy of the same ~200-line tail. The copies had drifted independently, which is how two audited bugs happened (audit A, 2026-08-06): the wechat responder captured its recipient at listener-registration time (A-M2), and all the command-dispatch sites ignored `CommandResult.startedTurn`, so a skill command on a fresh session kicked the agent with no listener attached and the reply vanished (A-M5).
21
21
 
22
22
  Three pieces:
23
23
 
24
- - **`InboundBridge<Route>`** — per-account bookkeeping for session listeners + their reply routes (`setRoute` / `getRoute` / `ensureListener` / `dropListener` / `closeAll`). The **reply route** — where to send, plus any per-message credential like wechat's context token — is a per-session value refreshed on **every** inbound message, and responders read it lazily at send time via `bridge.getRoute(sessionId)`. A listener registered once therefore can't lock onto a stale destination. Route entries are dropped together with their listener, so the map can't outgrow the listener set (the old per-channel context-token map never got cleaned up) — but **only once the responder has drained**: `close()` may return a promise (slack / feishu / wechat serialize their per-chunk sends and hand one back; telegram still fires each chunk and returns `void`), and the bridge defers the route deletion until it settles. Dropping the route synchronously stranded the tail of a split reply with nowhere to send (audit A-L3). `ensureListener` is idempotent per session; the account runner owns the bridge so `stopAccount` tears everything down with `closeAll()`, which walks the same per-listener teardown — deliberately no blanket `routes.clear()`, since that would delete the routes in-flight sends are about to read.
24
+ - **`InboundBridge<Route>`** — per-account bookkeeping for session listeners + their reply routes (`setRoute` / `getRoute` / `ensureListener` / `dropListener` / `closeAll`). The **reply route** — where to send, plus any per-message credential like wechat's context token — is a per-session value refreshed on **every** inbound message, and responders read it lazily at send time via `bridge.getRoute(sessionId)`. A listener registered once therefore can't lock onto a stale destination. Route entries are dropped together with their listener, so the map can't outgrow the listener set (the old per-channel context-token map never got cleaned up) — but **only once the responder has drained**: `close()` returns a promise (every responder extends the shared `ChunkedResponder`, which serializes its per-chunk sends and hands the chain's tail back), and the bridge defers the route deletion until it settles. Dropping the route synchronously stranded the tail of a split reply with nowhere to send (audit A-L3). `ensureListener` is idempotent per session; the account runner owns the bridge so `stopAccount` tears everything down with `closeAll()`, which walks the same per-listener teardown — deliberately no blanket `routes.clear()`, since that would delete the routes in-flight sends are about to read.
25
25
  - **`deliverInbound`** — the tail itself: `rememberLastActiveChat` → access-level projection → resolve-or-create the channel session → busy/compacting hint (hint only; delivery continues regardless) → `setRoute` → `ensureListener` → `appendUserMessage(uiText)` + `sendUserMessage("[channel: X | user: Y | thread: Z]\n" + agentText)`. Returns the session id.
26
26
  - **`dispatchChannelCommand`** — wraps the shared `dispatchCommand` and, when the result says `startedTurn`, wires route + listener exactly like the plain-message path, so no channel can forget it again. A call site that can't build a route logs instead of attaching a responder that could never send.
27
27
 
@@ -37,6 +37,7 @@ One bot (= one BotFather token) is bound to one workspace. A workspace can bind
37
37
 
38
38
  - **One Telegram user → many sessions (one active at a time)**
39
39
  - Session ID format: `tg_<userId>_<createdAtBase36>` (e.g. `tg_123456789_m1abc`)
40
+ - Keyed by the **sender** (`from.id`), not the chat: a user's DM and every group they talk to the bot in share the same sessions (see [Group chats](#group-chats))
40
41
  - Sessions live under the bot's bound workspace and use the highest-priority agent (falls back to `default` only when none exists)
41
42
  - The web side sees these sessions in the session list (labelled `Telegram: <userId>`)
42
43
  - Access level inherited from the bot account
@@ -61,7 +62,7 @@ Telegram-specific config JSON fields: `botToken`, `botUsername`, `allowedUsers`,
61
62
 
62
63
  Halo can send to a Telegram chat without that user having messaged the bot first — Telegram's Bot API allows `sendMessage(chatId, text)` for any known chat id. Halo only requires that the chat id come from a *trusted source*.
63
64
 
64
- The telegram cron-dispatcher (`channels/telegram/cron-dispatcher.ts`) registers itself with `cron/dispatcher.ts`'s registry at boot and **requires an explicit `chatId`** (numeric — Telegram private-chat ids equal user ids):
65
+ The telegram cron-dispatcher (`channels/telegram/cron-dispatcher.ts`) registers itself with `cron/dispatcher.ts`'s registry at boot and **requires an explicit `chatId`** (numeric — Telegram private-chat ids equal user ids, group ids are negative):
65
66
 
66
67
  - Cron jobs created from inside a telegram chat via the `cron` skill auto-pin the current chat id (target `telegram:<account>:<chatId>`).
67
68
  - Admin-UI cron jobs that don't specify a target run silently — the result shows in the cron log, nothing is pushed.
@@ -69,6 +70,8 @@ The telegram cron-dispatcher (`channels/telegram/cron-dispatcher.ts`) registers
69
70
 
70
71
  Earlier drafts considered "fan-out to every id in `allowedUsers`" or "fall back to `lastActiveChatId`", but in practice both pushed the cron output to strangers' chats. The cron creator's intent is "reach me, the person who set this up" — so the only target is what the caller passes explicitly.
71
72
 
73
+ The report is chunked like a chat reply — `splitText(text, TELEGRAM_TEXT_LIMIT)` (4000 chars, exported from `event-adapter.ts`; the Bot API rejects a message over 4096) — and the chunks are sent sequentially so a long report arrives in order. Before this the whole report went out in one `sendMessage` and anything over the cap failed outright. A failed chunk stops the send and is recorded as `chunk i/n: <error>` in the target's result row (chunks before it were delivered and stay); full success is one `ok` row regardless of chunk count. Same contract as the wechat dispatcher (see [cron.md](cron.md#dispatch-model)).
74
+
72
75
  ## Modules
73
76
 
74
77
  Files: `packages/server/src/channels/telegram/`
@@ -102,6 +105,20 @@ Routes: `packages/server/src/routes/telegram.ts`
102
105
 
103
106
  Photos / documents / voice / video notes trigger the same flow with the saved-file note in the text.
104
107
 
108
+ Builtin commands (`bot.command`) run the same `allowedUsers` check first and reply `handler.not_allowed` on a miss — before, only plain messages were gated, so any member of a group the bot sat in could run `/session` / `/workspace switch` at the account's access level. A command with no sender (`channel_post` in a channel the bot administers) is ignored.
109
+
110
+ ### Group chats
111
+
112
+ Supported, with Telegram's own visibility rules deciding what arrives:
113
+
114
+ - **What the bot receives** — Telegram, not halo, filters. Bots default to *privacy mode* in groups: only `/cmd@thisbot`, replies to the bot's messages, and a few edge cases are delivered; disable privacy mode in BotFather (`/setprivacy`) or make the bot a group admin to receive every message. Halo has no @-mention gate of its own.
115
+ - **Session isolation: per user.** The session prefix is `tg_<from.id>_` and the `activeOverrides` key is the user id, so each member gets their own session, and it is the *same* session they use in DM.
116
+ - **Reply destination: the chat the message came from.** The route is `{ chatId: ctx.chat.id }`, so a group message is answered in the group. The route is refreshed per message — a reply follows the user's latest chat (DM or group).
117
+ - **Permission check: the sender id.** `allowedUsers` matches `from.id` / `from.username`, never the chat id, for messages and commands alike.
118
+ - **Commands** — `bot.command` matches `/cmd@thisbot` for builtins; the skill-command path strips an `@<this bot>` suffix the same way (a suffix naming another bot is left alone and falls through to chat).
119
+ - **Restart restore** — `lastActiveChatId` stores the chat id; a group id is negative and doesn't name the member whose session it was, so `restoreReplyRoute` only restores private chats (`^\d+$`).
120
+ - **Cron** — the dispatcher accepts any numeric chat id, negative group ids included; a cron created from inside a group auto-pins the group.
121
+
105
122
  ## Slash commands (native Telegram /commands)
106
123
 
107
124
  | Command | Purpose |
@@ -117,10 +134,11 @@ Photos / documents / voice / video notes trigger the same flow with the saved-fi
117
134
 
118
135
  ## Event coalescing (TelegramResponder)
119
136
 
120
- Same strategy as WeChat:
137
+ Same strategy as WeChat — `TelegramResponder` is the shared `ChunkedResponder` (`channels/shared/responder.ts`) with `{ limit: 4000, logTag: 'Telegram', splitMidStream: true }`:
121
138
  - Buffer only `stream` events flagged `final` (the turn's wrap-up text; the filler the model emits before a tool call is dropped) until `complete`, then flush as one message
122
139
  - Flush happens on **any** `complete` — the responder doesn't read its `batchBoundary` flag, so a multi-round queue drain ships each merged turn as its own message instead of one blob (see [session.md](session.md#message-queue-and-drain))
123
- - If buffer exceeds 4000 chars (Telegram limit), split at paragraph boundary via the shared `splitText` (`channels/shared/chunk.ts`, used by all four responders)
140
+ - If buffer exceeds 4000 chars (Telegram limit), split at paragraph boundary via the shared `splitText` (`channels/shared/chunk.ts`), mid-stream and again on flush
141
+ - Every chunk goes through one serialized send chain, so a long reply arrives in order; `close()` returns the drain promise. Before this the telegram responder fired each chunk with `void dispatchChunk`, so the `sendMessage` calls raced and a >4000-char reply could land shuffled (the A-L3 fix had covered slack / feishu / wecom / wechat only)
124
142
  - `MEDIA:<path>` markers are intercepted and sent as native Telegram media
125
143
  - Errors flush immediately
126
144
  - Sub-agent events are dropped (visible in web UI only)
@@ -138,7 +156,7 @@ Outbound: the `send-file` skill produces `MEDIA:<path>` markers. The responder s
138
156
  - `.ogg/.oga` → `sendVoice`
139
157
  - anything else → `sendDocument`
140
158
 
141
- Path whitelist: a `full` account may send any readable path; other access levels are limited to the workspace or the OS temp dir (`isMediaPathAllowed(path, workspace, accessLevel)`). A blocked path — or any failed send — is reported to the chat as `t('handler.upload_failed')` (`⚠️ 文件上传失败:name — error`), same as slack / feishu / wecom; before this it was only a server log line.
159
+ Path whitelist: a `full` account may send any readable path; other access levels are limited to the workspace or the OS temp dir (`isMediaPathAllowed(path, workspace, accessLevel)`). A blocked path — or any failed send — is reported to the chat as `t('handler.upload_failed')` (`sendMediaOrReport` in `channels/shared/media.ts`) (`⚠️ 文件上传失败:name — error`), same as slack / feishu / wecom; before this it was only a server log line.
142
160
 
143
161
  ## Configuration
144
162
 
@@ -170,6 +188,6 @@ curl -X POST http://localhost:9527/api/telegram/accounts \
170
188
 
171
189
  ## Scope and out-of-scope
172
190
 
173
- Supported: private chat text, photos, documents; slash commands; per-session access level; user whitelist; media sending.
191
+ Supported: private and group chat text, photos, documents; slash commands; per-session access level; user whitelist; media sending.
174
192
 
175
- Not supported: group chats, inline queries, callback buttons, webhook mode (polling only).
193
+ Not supported: inline queries, callback buttons, webhook mode (polling only).
@@ -91,7 +91,9 @@ data: {"type":"complete"}
91
91
  data: {"type":"error","error":"..."}
92
92
  ```
93
93
 
94
- > **Batch-boundary `complete` (must be absorbed, never closes the stream)**: when a root session drains a queue of multiple messages, the server runs N merged turns and emits an internal `complete` with `batchBoundary: true` between rounds (see [session.md](session.md#message-queue-and-drain)). The SSE generators in `channels/web/handler.ts` flush the just-finished round's text on a `batchBoundary` complete but **do not** send a `complete` SSE frame and **do not** set `done` — the response stays open for the next round; only the **terminal** (unmarked) `complete` closes the HTTP stream. Without this guard a producer→sub-agent fan-out would truncate the web client after the first round. The `batchBoundary` flag is therefore an internal server-side event marker only — it is never serialized into the SSE payload a client sees, so a custom frontend just consumes one ordinary `complete` at the end. (ACP rides on this channel, so it inherits the same safe behavior.)
94
+ > **Batch-boundary `complete` (must be absorbed, never closes the stream)**: when a root session drains a queue of multiple messages, the server runs N merged turns and emits an internal `complete` with `batchBoundary: true` between rounds (see [session.md](session.md#message-queue-and-drain)). The SSE generators in `channels/web/handler.ts` (`listenSession().events()` plus the `createMediaBuffer()` event processor) flush the just-finished round's text on a `batchBoundary` complete but **do not** send a `complete` SSE frame and **do not** set `done` — the response stays open for the next round; only the **terminal** (unmarked) `complete` closes the HTTP stream. Without this guard a producer→sub-agent fan-out would truncate the web client after the first round. The `batchBoundary` flag is therefore an internal server-side event marker only — it is never serialized into the SSE payload a client sees, so a custom frontend just consumes one ordinary `complete` at the end. (ACP rides on this channel, so it inherits the same safe behavior.)
95
+
96
+ > **Listener lifetime**: `handleMessage` registers its session listener (`listenSession`) *before* the media save and `sendUserMessage` — it must be listening when the turn starts — and wraps everything from there to the end of `events()` in a `try/finally` that closes it. So a throw in between (media save failed, `sendUserMessage` threw), a `queued` result, or the end of the stream all drop the listener; before, only `events()`'s own finally and the `queued` branch did, and an early throw leaked it for the process lifetime. The unsubscribe is idempotent, so closing twice on the normal path is harmless. The route passes the request's abort signal (`c.req.raw.signal`) through to `events(signal)` — for `/web/chat` and a skill command's follow-on stream, as `/web/subscribe` already did — so a client that disconnects mid-turn ends the stream and drops the listener right away instead of at the turn's terminal `complete`. The turn itself keeps running; disconnecting is not `/web/stop`.
95
97
 
96
98
  #### Per-request overrides (`workspace`, `sessionId`, `agentId`)
97
99