@turmind/halo 1.4.6 → 1.5.1-alpha

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 (81) hide show
  1. package/README.md +0 -1
  2. package/admin-out/404.html +1 -1
  3. package/admin-out/_next/static/chunks/160.c52d3d6cf7bc8286.js +1 -0
  4. package/admin-out/_next/static/chunks/220.e250bbbf65d24094.js +1 -0
  5. package/admin-out/_next/static/chunks/362.a0c58d3bf9b1300b.js +1 -0
  6. package/admin-out/_next/static/chunks/449.c4b881b9ebdc556e.js +1 -0
  7. package/admin-out/_next/static/chunks/613.fb09e55e0a61d3fe.js +1 -0
  8. package/admin-out/_next/static/chunks/73.05abce37d312e880.js +1 -0
  9. package/admin-out/_next/static/chunks/803-7128009ee0e1c10e.js +1 -0
  10. package/admin-out/_next/static/chunks/839.a2729fd33013d763.js +1 -0
  11. package/admin-out/_next/static/chunks/868.bbb91afd48c9eb58.js +1 -0
  12. package/admin-out/_next/static/chunks/892.a35118e09d8d8efd.js +1 -0
  13. package/admin-out/_next/static/chunks/989-7420ee650083df43.js +1 -0
  14. package/admin-out/_next/static/chunks/app/page-fa5e935dbaa74165.js +1 -0
  15. package/admin-out/_next/static/chunks/webpack-1a264efd965db7ab.js +1 -0
  16. package/admin-out/_next/static/css/b8ffb5202b5a6619.css +3 -0
  17. package/admin-out/index.html +1 -1
  18. package/admin-out/index.txt +5 -5
  19. package/bundled-docs/design/agent.md +8 -7
  20. package/bundled-docs/design/architecture.md +9 -5
  21. package/bundled-docs/design/canvas-extensions.md +183 -0
  22. package/bundled-docs/design/cron.md +1 -1
  23. package/bundled-docs/design/express-self.md +9 -7
  24. package/bundled-docs/design/feishu.md +3 -1
  25. package/bundled-docs/design/observability.md +2 -0
  26. package/bundled-docs/design/prompt-system.md +6 -10
  27. package/bundled-docs/design/relay.md +22 -16
  28. package/bundled-docs/design/session.md +38 -26
  29. package/bundled-docs/design/slack.md +2 -2
  30. package/bundled-docs/design/storage.md +5 -5
  31. package/bundled-docs/design/telegram.md +4 -2
  32. package/bundled-docs/design/wechat.md +7 -5
  33. package/bundled-docs/design/wecom.md +2 -2
  34. package/bundled-docs/design/ws.md +29 -22
  35. package/bundled-docs/dev/add-model-provider.md +21 -18
  36. package/bundled-docs/dev/api.md +25 -24
  37. package/bundled-docs/dev/deploy.md +39 -9
  38. package/bundled-docs/dev/desktop-packaging.md +18 -8
  39. package/bundled-docs/dev/env.md +2 -3
  40. package/bundled-docs/dev/previews.md +9 -1
  41. package/bundled-docs/dev/tools.md +17 -82
  42. package/bundled-docs/guide/channels/wechat.md +1 -1
  43. package/bundled-docs/guide/chat.md +4 -2
  44. package/bundled-docs/guide/cli.md +0 -1
  45. package/bundled-docs/guide/delegation-and-access.md +1 -2
  46. package/bundled-docs/guide/workspace.md +11 -0
  47. package/bundled-docs/requirements/chat.md +11 -8
  48. package/bundled-docs/requirements/command.md +1 -12
  49. package/bundled-docs/requirements/editor.md +10 -0
  50. package/bundled-docs/requirements/settings.md +9 -0
  51. package/dist/index.js +2168 -1199
  52. package/package.json +2 -2
  53. package/templates/agents/deep-executor/agent.yaml +0 -3
  54. package/templates/agents/default/AGENT.md +0 -17
  55. package/templates/agents/default/agent.yaml +0 -1
  56. package/templates/agents/executor/agent.yaml +1 -1
  57. package/templates/models/aws-bedrock-claude-invoke.yaml +31 -0
  58. package/templates/models/aws-bedrock-mantle.yaml +42 -7
  59. package/templates/models/aws-bedrock-openai.yaml +53 -17
  60. package/templates/prompts/root/DELEGATION.md +58 -0
  61. package/templates/skills/extension/SKILL.md +70 -0
  62. package/templates/skills/extension/templates/ext.sh +112 -0
  63. package/templates/skills/halo/SKILL.md +3 -0
  64. package/templates/skills/send-file/SKILL.md +1 -1
  65. package/admin-out/_next/static/chunks/220.1ed70c874686122f.js +0 -1
  66. package/admin-out/_next/static/chunks/23-092ac3901411582e.js +0 -1
  67. package/admin-out/_next/static/chunks/362.5970ef15ec3d7b15.js +0 -1
  68. package/admin-out/_next/static/chunks/449.a403f7ad18054e3f.js +0 -1
  69. package/admin-out/_next/static/chunks/613.f314fc12e05a566c.js +0 -1
  70. package/admin-out/_next/static/chunks/73.a4617a551a8e176c.js +0 -1
  71. package/admin-out/_next/static/chunks/767.16a0b33b6f5e7965.js +0 -1
  72. package/admin-out/_next/static/chunks/839.e381287756453ffe.js +0 -1
  73. package/admin-out/_next/static/chunks/868.cc7fd84c9f2fd711.js +0 -1
  74. package/admin-out/_next/static/chunks/892.92b2ed455683b49a.js +0 -1
  75. package/admin-out/_next/static/chunks/989-6a0ec35b8ae2cb39.js +0 -1
  76. package/admin-out/_next/static/chunks/app/page-bf9c0b21ae431c92.js +0 -1
  77. package/admin-out/_next/static/chunks/webpack-ca926228f8cba1ff.js +0 -1
  78. package/admin-out/_next/static/css/c0ea31cd787f7aa7.css +0 -3
  79. package/bundled-docs/design/goal-mode.md +0 -144
  80. /package/admin-out/_next/static/{qYSILjqBjfgTggO0T8E7T → gRsZxPP4kTOmCtkHhJIZB}/_buildManifest.js +0 -0
  81. /package/admin-out/_next/static/{qYSILjqBjfgTggO0T8E7T → gRsZxPP4kTOmCtkHhJIZB}/_ssgManifest.js +0 -0
@@ -98,15 +98,17 @@ interface AgentSession {
98
98
  agent: ModelRuntime
99
99
  description: string
100
100
  output: string // all assistant text of the current turn (reset per turn)
101
- finalOutput: string // wrap-up text only (stopReason !== 'tool_use') — feeds auto-report / goal round / relay report
101
+ finalOutput: string // wrap-up text only (stopReason !== 'tool_use') — feeds auto-report / relay report
102
102
  lastActivityAt: string | null // ISO of the turn's latest text / tool_call / tool_result event; null at turn start — surfaced by get_session_output
103
103
  turnError: string | null // set when the turn died on an unrecoverable error — prefixes the report with an ABORTED marker
104
+ turnErrorKind: ModelErrorKind | null // classifyModelError kind of turnError (null exactly when turnError is); 'account' swaps the report's "resume it" hint for "fix the model config first"
104
105
  promise: Promise<string> | null // non-null = running
105
106
  abortController: AbortController | null
106
107
  messageQueue: QueuedMessage[] // single unified queue: user→agent AND agent→agent
107
108
  toolCallLog: Array<{name, inputHash}> // loop detection
108
109
  contextConfig: { maxTokens, compressAt }
109
110
  isCompacting: boolean
111
+ foldAfterCompact: string | null // queue text a Stop folded while a compact was in flight; clearCompacting lands it on the post-compact history
110
112
  interruptRequested: boolean // soft-interrupt flag — abort after the current tool_result
111
113
  selfKick: boolean // armed by the built-in continue_task tool; drainQueue turns it into one synthetic resume turn (one-turn lifetime)
112
114
  resumedAfterInterrupt: boolean // this turn was started by drainQueue after an interrupt — the only state in which continue_task arms selfKick
@@ -127,8 +129,8 @@ interface AgentSession {
127
129
 
128
130
  Fix (the rule: **agentId is the only identity; nothing that locates or persists may fall back to the display name**):
129
131
  - `processSessionEvent` stamps `agentId: session.agentId` on all five bare sub-session events.
130
- - `persistSubSession` no longer trusts the event-reconstructed `sub.agentId`; it resolves the authoritative id by `taskId` (in-memory session → db row, process-cached) — the same source `persistUIState` uses. This honours the "persistent operations must not depend on in-memory rebuilt state" rule.
131
- - `ui-log-builder`'s three `initSubSessionLog` call sites changed `?? agentName` → `?? ''`; an empty id is harmless because `persistSubSession` re-resolves the real id by `taskId`.
132
+ - `persistLog` (the sub-session branch) no longer trusts the event-reconstructed `sub.agentId`; it resolves the authoritative id by `taskId` (in-memory session → db row, process-cached) — the same source the root branch uses. This honours the "persistent operations must not depend on in-memory rebuilt state" rule.
133
+ - `ui-log-builder`'s three `initSubSessionLog` call sites changed `?? agentName` → `?? ''`; an empty id is harmless because `persistLog` re-resolves the real id by `taskId`.
132
134
 
133
135
  ### Session ID format
134
136
 
@@ -147,11 +149,11 @@ Hierarchical encoding: `root_id>child_segment>grandchild_segment`.
147
149
 
148
150
  Both true → sets `stoppedAt` + emits `agent_done` + `querySession`s the result back to the parent. The parent's `querySession` clears its own `stoppedAt`, handles the report, and may trigger its own `tryReportToParent` — bubbling up.
149
151
 
150
- **Abnormal-termination marker**: when the reported turn was killed by an unrecoverable error (retry budget exhausted / account-level failure), the session's `turnError` field holds the error text and the auto-report is prefixed with an explicit `[SUB-AGENT ABORTED: … Error: <text> … Re-dispatch with query_session(…) to resume.]` block. Without it the parent LLM consumed mid-turn fragments (or a literal "(no output)") as completed reports — the cron-era "sub-agent reported without finishing" incident, root-caused to a Bedrock h2 hang (`TimeoutError: http2 request did not get a response`, now also in the transient-transport retry list). Prefix, don't suppress: skipping the report would leave the parent waiting forever, the partial trace has diagnostic value, and `stoppedAt` is stamped as usual so the child never shows as falsely running. The marker is prepended *before* the truncation cap so it can never be sliced off. (`deliverGoalRound` has the same `finalOutput || output` shape without an error dimension — known gap, unfixed.)
152
+ **Abnormal-termination marker**: when the reported turn was killed by an unrecoverable error (retry budget exhausted / account-level failure), the session's `turnError` field holds the error text and the auto-report is prefixed with an explicit `[SUB-AGENT ABORTED: … Error: <text> … Re-dispatch with query_session(…) to resume.]` block. An **account** error (`turnErrorKind === 'account'`: 401/402/403, bad key, no balance) replaces the trailing hint with "this is a model account / credential / balance / permission problem — retrying or re-dispatching will fail the same way; fix the model configuration first (or tell the user), and do not resume it with `query_session` until then"; `RELAY TARGET ABORTED` ([relay.md](relay.md)) and goal mode's `WORKER ABORTED` get the same swap. Without it the parent LLM consumed mid-turn fragments (or a literal "(no output)") as completed reports — the cron-era "sub-agent reported without finishing" incident, root-caused to a Bedrock h2 hang (`TimeoutError: http2 request did not get a response`, now also in the transient-transport retry list). Prefix, don't suppress: skipping the report would leave the parent waiting forever, the partial trace has diagnostic value, and `stoppedAt` is stamped as usual so the child never shows as falsely running. The marker is prepended *before* the truncation cap so it can never be sliced off.
151
153
 
152
- The full finally-chain order is `tryReportToParent → deliverGoalRound → deliverRelayReport → releaseSession`: after the parent-report check, goal-bound **root** sessions get their round report delivered to the goal session (fire-and-forget; a no-op for everyone else — see [goal-mode.md](goal-mode.md#the-delivery-point-delivergoalround)), then a root whose row carries a `reply_to` back-pointer (it was dispatched from another workspace via `relay_send`) gets its wrap-up appended + sent into the caller session in that workspace (same subtree-quiet gate, `reply_to` cleared before sending — see [relay.md](relay.md)), then the session is released. Each hook decides on its own db column (`parent_id` / `goal_session_id` / `reply_to`), so the three never fire for the same session.
154
+ The full finally-chain order is `tryReportToParent → deliverGoalRound → deliverRelayReport → releaseSession`. `deliverGoalRound` is a retained legacy hook (goal mode's round delivery): it keys off the row's `goal_session_id` and works as before for any existing goal binding. After the parent-report check, a root whose row carries a `reply_to` back-pointer (it was dispatched from another workspace via `relay_send`) gets its wrap-up appended + sent into the caller session in that workspace (same subtree-quiet gate, `reply_to` cleared before sending — see [relay.md](relay.md)), then the session is released. Each hook decides on its own db column (`parent_id` / `goal_session_id` / `reply_to`), so the three never fire for the same session.
153
155
 
154
- - **Sibling-status injection for root** (`siblingStatusSuffix`): `tryReportToParent` early-returns for root (`parentId === null`) since there's no parent to bubble up to — so root never learns whether its *other* children are still running, and the root LLM could wrap up early after consuming just one child's report. When root consumes a child report (`querySession` idle branch + `drainQueue`), a sibling-status line is appended to the message fed to the LLM. "All sub-agents done" requires **both** no sibling running in the DB (`parentId = root AND stoppedAt IS NULL`) **and** an empty in-memory `messageQueue` — a child can be stopped while its report is still queued, so the DB check alone would falsely declare completion. The reporting child needs no identity exclusion: it stamped `stoppedAt` before the report was delivered, so `stoppedAt IS NULL` already excludes it (unless re-dispatched a new task, which clears `stoppedAt` — then it correctly counts as running). The line carries per-child `created` + `last active` timestamps so a capable model can tell a freshly dispatched sibling from an original-batch leftover. (Its `[System @ <iso>]` header used to be the *only* wall-clock signal the model ever saw; since 1.1.6 every turn carries an arrival stamp — see [Message queue and drain](#message-queue-and-drain).) Mid-tier parents are excluded by design — their `tryReportToParent` bubble-up already gates them on a fully-drained subtree.
156
+ - **Sibling-status injection for root** (`siblingStatusSuffix`): `tryReportToParent` early-returns for root (`parentId === null`) since there's no parent to bubble up to — so root never learns whether its *other* children are still running, and the root LLM could wrap up early after consuming just one child's report. When root consumes a child report (`querySession` idle branch + `drainQueue`), a sibling-status line is appended to the message fed to the LLM. "All sub-agents done" requires **both** no sibling running in the DB (`parentId = root AND stoppedAt IS NULL`) **and** an empty in-memory `messageQueue` — a child can be stopped while its report is still queued, so the DB check alone would falsely declare completion. The reporting child needs no identity exclusion: it stamped `stoppedAt` before the report was delivered, so `stoppedAt IS NULL` already excludes it (unless re-dispatched a new task, which clears `stoppedAt` — then it correctly counts as running). The line carries per-child `created` + `last active` timestamps so a capable model can tell a freshly dispatched sibling from an original-batch leftover. (Its `[System @ <iso>]` header used to be the *only* wall-clock signal the model ever saw; since 1.1.6 every turn carries an arrival stamp — see [Message queue and drain](#message-queue-and-drain).) The same rows are also emitted to the admin as a `system` event (root only) so a reviewer can see what root was told; that UI copy has a clock-less `[System]` header (the message carries its own `timestamp`) and relative ages (`started 46m ago, last active 2m ago`, via `formatAge`) instead of the ISO stamps the model gets. Mid-tier parents are excluded by design — their `tryReportToParent` bubble-up already gates them on a fully-drained subtree.
155
157
 
156
158
  - **interruptSession**: fire-and-forget abort of the in-flight turn — it sets `interruptRequested` so `runAgentTurn`'s unwind repairs (not errors), then aborts. It does **not** await or re-run: once the aborted turn unwinds, `runSession`'s finally sees the non-empty queue and `drainQueue` folds the queued message into one merged follow-up turn. The `interrupt_session` tool reaches this via `querySession(..., interrupt=true)` (enqueue + abort), so there is no separate re-run path or `skipRelease` bookkeeping.
157
159
 
@@ -161,17 +163,19 @@ A sub-session whose process was killed mid-run never got its `stoppedAt` written
161
163
 
162
164
  **Workspace-level gate (`.halo/runtime.lock`)**: `server.lock` ownership alone is not sufficient — two servers with different `HALO_HOME` (e.g. prod + dev) each hold their own `server.lock` yet can point at the *same* workspace directory, and one server's boot reconcile would batch-stop the other's actually-live sub-sessions (the incident this gate exists for). So the reconcile additionally requires `claimWorkspaceRuntime(workspaceRoot)` (`agents/workspace-runtime-lock.ts`) — a pid marker at `<workspace>/.halo/runtime.lock` with a liveness probe. Claim fails → skip reconcile and log a warning: **prefer missing a crash-orphan cleanup over stopping another process's live sessions**. `reconcileOrphansOnBoot: true` therefore means "reconcile if the workspace claim succeeds," not "always." Lock protocol details in [storage.md](storage.md#workspace-runtime-lock); known residual: when two servers both actively use one workspace long-term, the non-owner never reconciles — its own crash orphans stay un-cleaned until the owner restarts and takes over. The run-ledger eager boot loop below deals with the *un-opened* case for the same residual: a workspace whose runtime another live process owns is skipped before a SessionManager is even built, so the rows wait for a later boot instead of freezing behind a cached non-owner instance.
163
165
 
164
- The constructor chain is now `reconcile orphans → sweepActiveGoals → sweepInterruptedRuns` — all three gated on the same `reconcileOrphansOnBoot` + `.halo/runtime.lock` claim, so a workspace that fails the claim runs none of them.
166
+ **Shutdown** (`gracefulShutdown` in `index.ts`): for every loaded workspace, `sm.flushAll()` first lands the UI logs not yet persisted (a dirty root plus every live sub-session log — the pending 500ms debounces would die with the process), then `releaseWorkspaceRuntime` unlinks `.halo/runtime.lock` only when it holds *this* process's pid. A crash / SIGKILL leaves the file behind and takes the "pid dead → next boot takes over" path, so the release is an optimisation (a recycled pid can't read as a live holder), never a correctness dependency. The desktop app's `before-quit` (POSIX) holds the quit until the server process has actually exited (3s hard cap), so `killServer`'s SIGKILL fallback for a wedged server still runs instead of being dropped with the Electron process — otherwise a stuck server keeps the port and `server.lock`.
167
+
168
+ The constructor chain is now `reconcile orphans → sweepActiveGoals (retained legacy) → sweepInterruptedRuns` — all three gated on the same `reconcileOrphansOnBoot` + `.halo/runtime.lock` claim, so a workspace that fails the claim runs none of them.
165
169
 
166
170
  ### Run ledger — restart nudge for interrupted roots (`~/.halo/global/runs.db`)
167
171
 
168
- The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the **root** the server restarted — asked "done yet?" it still answers "waiting on reports" forever. Goal sessions already had a restart nudge (`sweepActiveGoals`); plain roots didn't, because "which session is running right now" only ever lived in memory.
172
+ The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the **root** the server restarted — asked "done yet?" it still answers "waiting on reports" forever. Plain roots had no restart nudge, because "which session is running right now" only ever lived in memory.
169
173
 
170
174
  **Ledger** (`db/runs-db.ts`): a global `~/.halo/global/runs.db` (same singleton pattern as `evo.db` / `cron.db` — `setRunsDb`/`getRunsDb`, WAL), one table `running_sessions(workspace TEXT, session_id TEXT, started_at INTEGER, PK(workspace, session_id))`. `runSession` inserts the id on entry and deletes it in its `finally`, so the steady state is an **empty table** — whatever is still there at boot is exactly the set of runs the previous process died in the middle of; no pid, no liveness probe needed. Only the long-lived server writes: `SessionManager.ledgerEnabled = opts.reconcileOrphansOnBoot === true`, the same flag that gates the orphan reconcile — cli / cron / TUI / evo-wrapper runs end with their terminal and never enter the table. Writes go through a private `ledgerWrite` wrapper that try/catches and only `console.warn('[RunLedger] ledger write failed …')` on failure: a sqlite hiccup (disk full, EIO) must not drop a message or strand a session at `promise = null` without a `complete` — worst case is one stale/missing row, i.e. one spurious or missed nudge at next boot.
171
175
 
172
- **Sweep** (`sweepInterruptedRuns`, `agents/run-ledger.ts`): runs from the SessionManager constructor right after `sweepActiveGoals`, under the same ownership gate. `drainRunning(workspace)` reads and deletes that workspace's rows in one transaction **before** any nudge goes out — the invariant being that a nudge's own re-dispatch inserts a fresh row, which must never be mistaken for a leftover on some future sweep. Ids are grouped by root (`id.split('>')[0]`); each root gets one append-then-send nudge (text starts `[System] The server restarted at <ISO> while you were mid-turn…`). The wording is about the root's *own* turn first: history up to the last completed tool call is intact, the in-flight call was cut off and its side effects may or may not have landed (check before repeating anything destructive), continue the task from there — don't start over, don't wait for input. Sub-agents come second (stopped-but-revivable via `query_session`; sub-sessions are not listed — the root's own transcript / `session_list` has them). The first version talked only about sub-agents, so a root killed mid tool-loop went hunting for stopped children, found old ones, and declared "nothing was interrupted" instead of resuming. Skipped per root: row missing or `archivedAt` set; `goal` column set (a goal session — `sweepActiveGoals` owns its nudge); a goal-bound worker whose goal is `running` (G's own restart nudge re-dispatches it; a direct nudge would land a bogus round report on G) — but **only** `running`: `intake`/`paused` workers are nudged like plain roots, safe because `sendUserMessage` bypasses the routing overlay and `deliverGoalRound` returns early for a non-running goal; `cron-*` ids; `internal: true` agents (synchronous read of `agent.yaml`, since the sweep runs from a sync constructor). Principle: exactly one entry point gets woken per tree — a plain tree wakes its root, a `running` goal tree wakes G, a cron tree wakes nobody (it waits for its next fire).
176
+ **Sweep** (`sweepInterruptedRuns`, `agents/run-ledger.ts`): runs from the SessionManager constructor right after the retained legacy `sweepActiveGoals`, under the same ownership gate. `drainRunning(workspace)` reads and deletes that workspace's rows in one transaction **before** any nudge goes out — the invariant being that a nudge's own re-dispatch inserts a fresh row, which must never be mistaken for a leftover on some future sweep. Ids are grouped by root (`id.split('>')[0]`); each root gets one append-then-send nudge (text starts `[System] The server restarted at <ISO> while you were mid-turn…`). The wording is about the root's *own* turn first: history up to the last completed tool call is intact, the in-flight call was cut off and its side effects may or may not have landed (check before repeating anything destructive), continue the task from there — don't start over, don't wait for input. Sub-agents come second (stopped-but-revivable via `query_session`; sub-sessions are not listed — the root's own transcript / `session_list` has them). The first version talked only about sub-agents, so a root killed mid tool-loop went hunting for stopped children, found old ones, and declared "nothing was interrupted" instead of resuming. Skipped per root: row missing or `archivedAt` set; existing goal-mode bindings (a session with a `goal` column set, or a worker whose bound goal is still `running` — the retained `sweepActiveGoals` owns those nudges, unchanged); `cron-*` ids; `internal: true` agents (synchronous read of `agent.yaml`, since the sweep runs from a sync constructor). Principle: exactly one entry point gets woken per tree — a plain tree wakes its root, a `running` goal tree (existing binding) wakes its goal session, a cron tree wakes nobody (it waits for its next fire).
173
177
 
174
- **Eager boot** (`index.ts`): rather than waiting for someone to open a workspace, right after constructing `SessionManagerRegistry` (before `bootChannels`) the server calls `listRunningWorkspaces()` and for each workspace with leftover rows whose `.halo` dir still exists: `claimWorkspaceRuntime(ws)` first — if another live server owns it, `continue` (rows stay for a later boot, and no SessionManager is built, since caching a non-owner SM would freeze "not owner" for the whole process lifetime) — otherwise `registry.getOrCreate(ws)`, which fires the same constructor chain (claim → reconcile → `sweepActiveGoals` → `sweepInterruptedRuns`). An interrupted root with nobody around is exactly the one that must nudge itself without a human opening the tab first.
178
+ **Eager boot** (`index.ts`): rather than waiting for someone to open a workspace, right after constructing `SessionManagerRegistry` (before `bootChannels`) the server calls `listRunningWorkspaces()` and for each workspace with leftover rows whose `.halo` dir still exists: `claimWorkspaceRuntime(ws)` first — if another live server owns it, `continue` (rows stay for a later boot, and no SessionManager is built, since caching a non-owner SM would freeze "not owner" for the whole process lifetime) — otherwise `registry.getOrCreate(ws)`, which fires the same constructor chain (claim → reconcile → retained `sweepActiveGoals` → `sweepInterruptedRuns`). An interrupted root with nobody around is exactly the one that must nudge itself without a human opening the tab first.
175
179
 
176
180
  `runs.db`/`-wal`/`-shm` join the sandbox `hidden_files` default alongside `evo.db` / `cron.db` (see [storage.md](storage.md)). Known gap carried from the design doc: `<ISO>` in the nudge is sweep time, not the actual interruption time — for a workspace that sat locked by another server before this boot could claim it, that's a real but accepted drift.
177
181
 
@@ -187,7 +191,7 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
187
191
 
188
192
  **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.
189
193
 
190
- **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` / `deliverGoalRound`) is the same accumulation on the server side.
194
+ **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.
191
195
 
192
196
  **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):
193
197
  - 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.
@@ -206,11 +210,13 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
206
210
  **`continue_task` — resume after interrupt.** All three interrupt paths share a say/do gap: the interrupted turn unwinds, `drainQueue` runs the new message as a fresh turn, the model answers it and `end_turn`s — it *says* "continuing" but the interrupted work never resumes (worse for sub-agents, whose answer is then auto-reported to the parent as if it were the finished result). The built-in `continue_task` tool (every agent, no params — the one truly unconditional tool: `activate_skill` is gated on `skills`, session tools on `team`; wired in `session-agent-builder`) closes it with a flag pair on `AgentSession`:
207
211
  - **`resumedAfterInterrupt`** is snapshotted from `interruptRequested` at the top of every drained batch (before the per-batch reset) and set `false` for the opening turn. `requestSelfKick` — the tool backend — returns `not_interrupted` and sets nothing unless it is `true`, so a normal turn can't arm the flag: **kicks ≤ interrupts**, no perpetual motion.
208
212
  - **`selfKick`** lives exactly one turn: reset at the top of every drained batch. If that reset drops a `true` (the model armed it, then got interrupted *again* before the kick fired), a one-line `[System] … flag … was reset by this interrupt … call continue_task again` note is appended to that batch's merged input — the model can't see the flag, so it's told.
209
- - **Kick position**: inside `drainQueue`'s while-loop, after `runAgentTurn` and **before** the `batchBoundary` check. When `selfKick` is set, the queue is empty **and `interruptRequested` is false**, it pushes a synthetic `[System] You called continue_task: … resume it now …` user turn (`CONTINUE_TASK_KICK`) and traces it as a `type:'user'` event with `report: true` (not a `system` event — the kick is a raw user turn, so it needs a UI user row or `deleteExchange`'s UI span and `deleteRawTurn`'s raw span diverge; traced at enqueue like every queued message, routed into a sub-agent's log via `taskId`); the existing boundary `complete` then fires naturally so block channels flush the answer as its own message and the kick turn gets its own bubble. The next iteration sees `interruptRequested === false` → `resumedAfterInterrupt = false`, so the kick turn cannot re-arm. If something else landed mid-turn the kick is skipped — the next iteration's reset + note handles it (the newest message might be "stop"; the model decides with that in view, not the loop). The `!interruptRequested` gate covers the other abort path: **esc / `/interrupt` never kicks** — `interruptSession` sets the flag and aborts *without* enqueuing, so the queue check alone would have let an esc'd turn resume itself; esc means "stop what you're doing". Whenever the loop is about to exit on an empty queue the flag is cleared so it never dangles. It is deliberately **not** in `runSession`'s `finally`: `tryReportToParent` / `deliverGoalRound` / `deliverRelayReport` fire there and must see the *finished* work, so the kick turn has to complete first.
210
- - **Interim report before the kick** (`sendInterimReport`, called right before `CONTINUE_TASK_KICK` is queued): the kick turn resets `output` / `finalOutput`, and every end-of-run report (`tryReportToParent` / `deliverRelayReport`) reads only the last turn — so the answer to the interrupting message would never reach whoever asked. The per-turn reset stays (get_session_output, the session file, goal no-progress hashing and the report cap depend on it); instead the answer is snapshotted here and forwarded **only when the batch came from the party this session owes a report**: a sub-agent whose batch has a message with `sourceSessionId === parentId` → `querySession(parent, self, "[Interim report: … Not the final result.]\n\n<answer>")`, **without** stamping `stoppedAt` or emitting `agent_done` (the parent's sibling-status still lists the child as running → "Do NOT wrap up yet"; the real auto-report follows at the end); a root whose batch has a relay-prefixed message → `deliverRelayInterim` (keeps `reply_to`, see [relay.md → Interim report](relay.md#interim-report-continue_task)). Nothing for local user chat (the channel already streamed the answer and a `batchBoundary` complete flushes it), a sibling/other-session message, an errored answer turn, or a goal worker (G's steering carries neither mark; an interim would wake G mid-round and its next `query_session` would interrupt W again — the round report is unchanged and counts once). Capped at `limits.autoReportMax`.
211
- - **Stop / delete / archive never resurrect**: `stopSession` and `stopUserSession` clear `selfKick` next to `interruptRequested`; `deleteSession` (root + children) and `archiveSession` clear it right after nulling `abortController` and *before* awaiting `session.promise` (the aborted turn's drain still runs inside that promise and would otherwise fire a real kick turn); and `requestSelfKick` returns `no_turn` when `session.abortController === null` — Stop nulls it *before* the abort lands, so a tool callback that completes after a Stop can't re-arm the flag. `enqueueUserMessage` (admin WS) mirrors `sendUserMessage` and does **not** set `interruptRequested` while compacting — there is no live turn, and `endCompact`'s drain would otherwise snapshot it into `resumedAfterInterrupt` for a turn that interrupted nothing.
213
+ - **Kick position**: inside `drainQueue`'s while-loop, after `runAgentTurn` and **before** the `batchBoundary` check. When `selfKick` is set, the queue is empty **and `interruptRequested` is false**, it pushes a synthetic `[System] You called continue_task: … resume it now …` user turn (`CONTINUE_TASK_KICK`) and traces it as a `type:'user'` event with `report: true` (not a `system` event — the kick is a raw user turn, so it needs a UI user row or `deleteExchange`'s UI span and `deleteRawTurn`'s raw span diverge; traced at enqueue like every queued message, routed into a sub-agent's log via `taskId`); the existing boundary `complete` then fires naturally so block channels flush the answer as its own message and the kick turn gets its own bubble. The next iteration sees `interruptRequested === false` → `resumedAfterInterrupt = false`, so the kick turn cannot re-arm. If something else landed mid-turn the kick is skipped — the next iteration's reset + note handles it (the newest message might be "stop"; the model decides with that in view, not the loop). The `!interruptRequested` gate covers the other abort path: **esc / `/interrupt` never kicks** — `interruptSession` sets the flag and aborts *without* enqueuing, so the queue check alone would have let an esc'd turn resume itself; esc means "stop what you're doing". Whenever the loop is about to exit on an empty queue the flag is cleared so it never dangles. It is deliberately **not** in `runSession`'s `finally`: `tryReportToParent` / `deliverRelayReport` fire there and must see the *finished* work, so the kick turn has to complete first.
214
+ - **Interim report at turn end** (`sendInterimReport`, called in `drainQueue` right after `runAgentTurn` returns, before the kick block): the next turn resets `output` / `finalOutput`, and every end-of-run report (`tryReportToParent` / `deliverRelayReport`) reads only the last turn — so a turn that *another turn follows* would never reach whoever asked. Trigger: `(selfKick && !interruptRequested) || messageQueue.length > 0` (a kick is coming, or a message is already queued — the kick itself is skipped then, the interim must not be) **and** the turn was an answer (`selfKick` set, or `finalOutput !== ''` from a natural `end_turn`). An esc after `continue_task` fails the first half → no kick → the run ends → the final report carries the turn, no interim. The per-turn reset stays (get_session_output, the session file and the report cap depend on it); instead the turn's text is snapshotted here and forwarded **only when the batch came from the party this session owes a report** — the pure `interimDoor(parentId, batch)` picks the recipient (`'parent'` / `'relay'` / null): a sub-agent whose batch has a message with `sourceSessionId === parentId` → `querySession(parent, self, "[Interim report · status: still running] … Not the final result.\n\n<answer>")`, **without** stamping `stoppedAt` or emitting `agent_done` (the parent's sibling-status still lists the child as running → "Do NOT wrap up yet"; the real auto-report follows at the end); a root whose batch has a relay-prefixed message → `deliverRelayInterim` (keeps `reply_to`, see [relay.md → Interim report](relay.md#interim-report-continue_task)). Both paths share `deliverInterim(session, 'parent' | 'relay')`. The body is the **whole** turn's `output`, not `finalOutput`: the response carrying the `continue_task` tool_use is `stopReason: tool_use`, so its text is `final: false` and never lands in `finalOutput` — an answer written before the call would otherwise be dropped. Nothing for local user chat (the channel already streamed the answer and a `batchBoundary` complete flushes it), a sibling/other-session message, or an errored turn. Capped at `limits.autoReportMax`.
215
+ - **Opening-turn interim** (`sendOpeningInterim`, in `runSession`'s `runFn` right after the `hasFirst` turn): the same loss happens when a follow-up is queued while the opening turn runs — the drain's first turn resets the opening wrap-up. Fires when `messageQueue` is non-empty, `finalOutput !== ''` and no `turnError`. A sub-agent's opening message is its parent's dispatch, so it always owes the parent. A root owes its relay caller only when the opening message itself carries the relay prefix (same `interimDoor` as the drain path) **and** a `reply_to` row exists — a pending `reply_to` alone is not enough, or a local user chatting into a root that still owes a relay report would have their answer forwarded to the caller; otherwise nothing.
216
+ - **Deferred interim** (`sendDeferredInterim`, in `runSession`'s finally right before `tryReportToParent` / `deliverRelayReport`): a session waiting on its own sub-agents is asked by its caller (`relay_send` / the parent's `query_session`) and answers, and the run ends with a child still running — both end-of-run reports skip (subtree not quiet), and the child's later report wakes a new run whose first turn resets `output`, so the answer reached no one. The run's last turn (`RunLastTurn { batch, waitingOnChildren }`, refreshed before the opening turn and every drained batch) sends an interim through `sendInterimReport` when it answered the owed party (`interimDoor`) **while a child was already running at turn start** (`waitingOnChildren` — door first, db lookup only past it, so local chat / child reports / goal rounds / kicks never query; turn *start*, because the dispatch turn starts its child mid-turn and owes the final report, not an interim), `finalOutput !== ''`, no `turnError`, and the subtree is still busy at run end (`listActiveChildren`, the same db gate the two reports read synchronously right after: children running → they skip and this sends; none → they report the turn as final and this doesn't). `reply_to` is kept, so the final report still fires once. Never overlaps the drain path: a turn another turn follows is never the last.
217
+ - **Stop / delete / archive never resurrect**: `stopSession` and `stopUserSession` clear `selfKick` next to `interruptRequested`; `deleteSession` (root + children) and `archiveSession` clear it right after nulling `abortController` and *before* awaiting `session.promise` (the aborted turn's drain still runs inside that promise and would otherwise fire a real kick turn); and `requestSelfKick` returns `no_turn` when `session.abortController === null` — Stop nulls it *before* the abort lands, so a tool callback that completes after a Stop can't re-arm the flag. `enqueueUserMessage` (admin WS) mirrors `sendUserMessage` and does **not** set `interruptRequested` while a compact runs outside a live model loop (manual `/compact`, or the turn-end auto-compact — `abortController` already null) — there is no live turn, and the drain would otherwise snapshot it into `resumedAfterInterrupt` for a turn that interrupted nothing. A mid-turn auto-compact (`beforeCallModel`, `abortController` still set) does sit inside a live turn, so the flag is set and the turn yields after its next tool instead of running to its natural end with the message still queued.
212
218
 
213
- **Stop preserves, archive discards.** Both `stopSession` and `stopUserSession` (the user Stop button) fold the **whole** `messageQueue` into `agent.messages` as a user turn (user entries raw, agent entries with their `(from: session X)` prefix) **before** aborting, then `repairConversationMessages` — a stop **parks** the work, it never drops a queued message (mirrors interrupt, which also never loses one). `archiveSession`, by contrast, clears the queue without folding — archiving a subtree is a deliberate discard.
219
+ **Stop preserves, archive discards.** Both `stopSession` and `stopUserSession` (the user Stop button) fold the **whole** `messageQueue` into `agent.messages` as a user turn (user entries raw, agent entries with their `(from: session X)` prefix) **before** aborting, then `repairConversationMessages` — a stop **parks** the work, it never drops a queued message (mirrors interrupt, which also never loses one). While a compact is in flight the fold is parked in `foldAfterCompact` and lands when the compact ends (see [Compaction paths](#compaction-paths)). `archiveSession`, by contrast, clears the queue without folding — archiving a subtree is a deliberate discard.
214
220
 
215
221
  #### Process-group kill on abort
216
222
 
@@ -246,7 +252,7 @@ The gate lives at the **tool callback layer** (`agents/session-tools.ts`), not i
246
252
 
247
253
  ### Viewing a sub-session's live log (`getSessionView`)
248
254
 
249
- Because every event reduces into the **root's** UIState (a sub-agent's stream / tool calls land in `rootState.subSessionLogs[subId]`, keyed by the sub's full id), `getSessionView(subId)` must read from there — not from `uiStore.ensureUIState(subId)`, which is keyed by the sub's own id and would only ever see a cold disk-seeded snapshot (and, since the root is self-driven, never re-read disk → a live viewer would freeze on the first frame). So `getSessionView` special-cases a sub-session whose root holds it in `subSessionLogs` (read via `uiStore.getCachedUIState(rootId)`): it snapshots that sub-log directly (in-flight buffers included, fresher than disk). For the root-view path it calls `uiStore.prepareForView(sessionId, selfDriven)`, which evicts a stale cache when the session isn't self-driven so the snapshot reflects disk. Once the sub finishes, `agent_done` deletes the sub-log and the call falls through to the on-disk file. This is what lets the TUI `/log` viewer refresh a running sub-agent's log in real time.
255
+ Because every event reduces into the **root's** UIState (a sub-agent's stream / tool calls land in `rootState.subSessionLogs[subId]`, keyed by the sub's full id), `getSessionView(subId)` must read from there — not from `uiStore.ensureUIState(subId)`, which is keyed by the sub's own id and would only ever see a cold disk-seeded snapshot (and, since the root is self-driven, never re-read disk → a live viewer would freeze on the first frame). So `getSessionView` special-cases a sub-session whose root holds it in `subSessionLogs` (read via `uiStore.getCachedUIState(rootId)`): it snapshots that sub-log directly (in-flight buffers included, fresher than disk). For the root-view path it calls `uiStore.prepareForView(sessionId, selfDriven)`, which evicts a stale cache when the session isn't self-driven so the snapshot reflects disk. Once the sub finishes, `agent_done` flushes its log whole to its own file and deletes the in-memory sub-log, so the call falls through to the on-disk file (the next `query_session` re-seeds the sub-log from that file). This is what lets the TUI `/log` viewer refresh a running sub-agent's log in real time.
250
256
 
251
257
  ### Idle UIState eviction
252
258
 
@@ -254,7 +260,7 @@ Because every event reduces into the **root's** UIState (a sub-agent's stream /
254
260
 
255
261
  An idle-sweep candidate is refused eviction while `hasActiveWorkInTree(rootId)` is true — any session in the root's tree (root or `root>…` descendants) with an in-flight `promise` or `isCompacting` exempts the **whole tree**, because a fire-and-forget sub-agent's live stream/tool buffers hang off the *root's* UIState even after the root itself released between turns. This was chosen over the two eviction points that looked simpler but fail on verified facts: dropping in `releaseSession` (turn end) would force an admin-open or mid-conversation channel session to re-read its full file from disk on every next message — hot-path I/O; "drop when no active subscribers" would exempt exactly the leak's dominant source, since a channel account's `InboundBridge` listener subscribes permanently.
256
262
 
257
- `dropUIState` flushes a pending debounced persist **before** evicting — otherwise an immediate rehydration would read the shorter on-disk file, and that rehydrated state's next persist would overwrite the orphaned timer's later, complete write. After eviction, the next access (`ensureUIState`) rehydrates from disk exactly as it already does for a cold session — same code path, no special-casing.
263
+ `dropUIState` flushes a pending debounced persist — the root's and its tree's pending sub-session writes — **before** evicting — otherwise an immediate rehydration would read the shorter on-disk file, and that rehydrated state's next persist would overwrite the orphaned timer's later, complete write. After eviction, the next access (`ensureUIState`) rehydrates from disk exactly as it already does for a cold session — same code path, no special-casing.
258
264
 
259
265
  ### SQLite metadata
260
266
 
@@ -272,7 +278,7 @@ Table `agent_sessions` holds metadata only (no runtime state):
272
278
  | created_at / updated_at | INTEGER | Epoch ms |
273
279
  | stopped_at | INTEGER | null = active |
274
280
  | archived_at | INTEGER | null = not archived |
275
- | goal / goal_session_id | TEXT | Goal mode: binding JSON on G's row, back-pointer on W's — see [goal-mode.md](goal-mode.md) |
281
+ | goal / goal_session_id | TEXT | Retained legacy columns (goal mode binding JSON / back-pointer); existing bindings are unchanged and still honored |
276
282
  | reply_to | TEXT | Relay: JSON `{ workspace, sessionId }` of the caller in another workspace, set by `relay_send`, cleared when the report is delivered — see [relay.md](relay.md) |
277
283
  | title / exchange_count / context_tokens / total_output_tokens | TEXT / INTEGER | List-visible metadata mirrored from the session file header; null = row predates the columns |
278
284
 
@@ -301,6 +307,8 @@ A `toolUseId`-based algorithm that repairs message arrays damaged by abort / int
301
307
 
302
308
  **Partial batches land, only the cut tool is synthesized** (`agent-loop.ts`, the tool loop's `try/finally`): in a parallel `tool_use` batch the loop pushes the accumulated `tool_result` user message in a `finally`, so both interrupt exits — the loop's own cancel check (soft interrupt: the consumer aborts after a `tool_result`, the next tool sees the signal) and the consumer breaking its `for await` (hard interrupt: finishes the generator at the `yield` via `.return()`) — still land the results that finished. Before this, either exit skipped the push, so every completed result was lost and repair marked the **whole** batch `[interrupted]`; the model then re-ran work that had already happened, which for side-effecting calls (commit, append, send) is a double execution — and `continue_task`'s "re-issue interrupted calls" instruction made that hazard live. The tool the abort landed on is dropped on purpose (its result is a killed shell's partial output), so repair pairs exactly that id — and any never-started ones — with the do-not-retry marker.
303
309
 
310
+ **Partial streamed text lands** (`agent-loop.ts`, `run()`'s model-call `try/finally`): every streaming provider re-establishes the non-streaming contract on a cancel — it throws `AbortError` and returns no partial result — so the text already streamed existed only as `text_delta` events. The UI log persisted it (`appendStream`), the user read it, but `agent.messages` had no assistant turn: the next user message coalesced into the dangling user turn and, asked "what did you just write?", the model answered it had no record. The loop now accumulates `text_delta`s per model call and, when the call did **not** resolve and the **caller's** `cancelSignal` is aborted, pushes them as one assistant text block suffixed with `INTERRUPTED_REPLY_MARKER` (`[reply interrupted by the user here — the text above is what was shown before the cut]`). Same two exits as the tool batch above: the provider's rethrown `AbortError`, and the consumer breaking its `for await` at a delta `yield` (`runAgentTurn`'s `if (signal.aborted) break` → `.return()` → `finally`). Deliberately narrow: **text only** — a partial thinking block has no signature and Anthropic / Bedrock reject unsigned thinking in history, and a partial `tool_use` never reaches this point (the providers throw rather than hand back a half-built input that would be JSON-parsed / paired with a synthetic `tool_result` and make the model believe it made the call). **Gated on the caller's signal, not the idle-timeout controller**: a `MODEL_TIMEOUT_ERROR` is retried by `runAgentTurn` and the retry re-streams from scratch, so landing there would duplicate the text. No new event is emitted (the UI already has the partial) and `output` / `finalOutput` are untouched. Repair is a no-op on the landed block (Phase 1 only strips *empty* text blocks); the next `run()` sees a trailing assistant turn and pushes the user message fresh instead of coalescing. An abort before the first text delta still leaves the dangling user turn, exactly as before.
311
+
304
312
  **Provider-side consumers**: the OpenAI-style agents (DeepSeek / Kimi / Doubao / Hunyuan / Zhipu / Mantle / generic OpenAI) convert a user turn's `tool_result` blocks into tool-role messages — any non-`tool_result` content coalesced into the same turn (e.g. the synthesized result landing alongside real user text, or a stop-fold) is emitted as a following user message rather than silently dropped.
305
313
 
306
314
  ### UI-side interrupt marker (`markPendingToolCallsInterrupted`)
@@ -312,7 +320,7 @@ The synthesized `[interrupted]` result above is **model-facing only** (`agent.me
312
320
  3. **`stopSession`**'s cascade — per descendant, before awaiting the aborted promise (runSession's finally emits `complete`, which flushes and clears the pending buffers this scans).
313
321
  4. **`stopUserSession`** (the user Stop button).
314
322
 
315
- Idempotent — completed tools (output already set) are never overwritten. Two supporting details: `session-ui-store.flushSubSession()` persists a stopped sub-session's log explicitly (it never gets a later usage/agent_done to trigger its usual flush, so the marker would otherwise die with the process); `ui-log-builder.setToolResult` attaches an incoming result to the **first** entry without an output (not the last entry) and never overwrites a completed one — fixes reversed attachment under parallel tool calls and keeps the marker idempotent.
323
+ Idempotent — completed tools (output already set) are never overwritten. Two supporting details: `session-ui-store.flushSubSession()` writes a stopped sub-session's log to its own file immediately (the synthetic results only arm the 500ms debounce, and the interrupt is a turn boundary — the marker shouldn't ride a timer); `ui-log-builder.setToolResult` attaches an incoming result to the **first** entry without an output (not the last entry) and never overwrites a completed one — fixes reversed attachment under parallel tool calls and keeps the marker idempotent.
316
324
 
317
325
  ### SDK block format handling
318
326
 
@@ -343,7 +351,7 @@ Frontend network issues don't affect the backend:
343
351
  2. **Grace period**: fixed `config.timeout.sessionGrace` (5 min default) — a single `setTimeout`, no auto-extension
344
352
  3. **Event buffering**: the detached handler uses `bufferDetachedNotification` to buffer structural events in `pendingEvents[]`. All state (messageLog / tokens / etc.) lives in SessionManager's UIState, not duplicated in the handler.
345
353
  4. **Reconnect**: a `subscribe` with the same sessionId loads UIState from SessionManager, replays `pendingEvents`, and — if still running — resumes live streaming
346
- 5. **Detach-save dirty gate**: every WS write-back path (disconnect cleanup, grace expiry, session switch/clear, backgroundSaves) funnels through `saveSession()`, which skips any UIState that isn't **dirty** — i.e. this process never reduced an event / appended a notification / replaced the log since the last successful persist (`SessionUIStore.isUIStateDirty`). A clean state is either a pure disk seed (built by `prepareForView` to *view* a session another process is driving — e.g. a cron `halo cli` child appending to the same file) or already flushed; writing it back would clobber the fresher file with a frozen snapshot. This was the cron-session UI-log truncation incident: admin viewed a cron session, closed the tab, and the grace-expiry save erased the cli child's final minutes of messages (`rawMessages`/`output` survived only via `saveSessionToFile`'s read-merge). The flag is cleared on successful `persistUIState` (kept on failure so a later save retries) and on `dropUIState`/purge/`prepareForView` re-seed so a stale flag never leaks onto a fresh disk copy.
354
+ 5. **Detach-save dirty gate**: every WS write-back path (disconnect cleanup, grace expiry, session switch/clear, backgroundSaves) funnels through `saveSession()`, which skips any UIState that isn't **dirty** — i.e. this process never reduced an event / appended a notification / replaced the log since the last successful persist (`SessionUIStore.isUIStateDirty`). A clean state is either a pure disk seed (built by `prepareForView` to *view* a session another process is driving — e.g. a cron `halo cli` child appending to the same file) or already flushed; writing it back would clobber the fresher file with a frozen snapshot. This was the cron-session UI-log truncation incident: admin viewed a cron session, closed the tab, and the grace-expiry save erased the cli child's final minutes of messages (`rawMessages`/`output` survived only via `saveSessionToFile`'s read-merge). A sub-session event changes only that sub's own file, so it does not mark the root dirty; `flushSession` likewise rewrites the root's file only when it is dirty. The flag is cleared on successful root `persistLog` (kept on failure so a later save retries) and on `dropUIState`/purge/`prepareForView` re-seed so a stale flag never leaks onto a fresh disk copy.
347
355
 
348
356
  ## Resilient execution loop
349
357
 
@@ -402,9 +410,9 @@ Three entry points with different quality / safety trade-offs:
402
410
 
403
411
  | Trigger | Path | Compaction used | Rationale |
404
412
  |---|---|---|---|
405
- | 80% 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 | 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. |
413
+ | 80% 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. |
406
414
  | 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. |
407
- | User `/session compact` (web, WeChat) | `commands/compact.ts` / `SessionManager.compactSession` | **Self-compact**, with **local fallback** on timeout/error | User explicitly requested it; self-compact reuses the cached context so it's fast. Falls back to local if anything goes wrong. |
415
+ | 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`. |
408
416
 
409
417
  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.
410
418
 
@@ -412,15 +420,19 @@ The pre-run snapshot is load-bearing, not an optimization: `agent.run()` coalesc
412
420
 
413
421
  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.
414
422
 
415
- All paths share the same split logic — the private `compactCut(messages)` on SessionManager (single source of truth, 3 call sites: `selfCompactSession` + the two feasibility gates below): 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.
423
+ 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.
424
+
425
+ **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.
426
+
427
+ **Auto-compact local fallback.** When the LLM summary throws, idle-times-out, or returns nothing, `localCompactFallback` runs the no-LLM compact instead of leaving the session over the threshold: `localCompactMessages` → tail `microCompactMessages(tail, 1)` → re-estimate tokens. The notice is `Auto-compacted N older messages (local fallback — LLM summary failed: <reason>)` (reason truncated to 80 chars + `…`), emitted with `compactEnd: true` so the WS layer sends `compact:done` and the admin leaves its compacting state. The history is compacted locally rather than left over the threshold, so a failing summarize is not re-run on every later model call.
416
428
 
417
- **Notification contract — a "Compacting context…" preflight is only announced when compaction will actually run, and always gets an outcome line.** Both self-compact entry points (`maybeAutoCompact`, manual `compactSession`) check `compactCut() === 0` *before* emitting the preflight and bail silently when there's nothing to compact. After the preflight, every path closes it out: success pairs it with `Auto-compacted N older messages` / `Context compacted: …`; an empty LLM summary (a thinking-heavy model can legally spend its entire response on non-text blocks) emits `Compaction skipped — no summary produced`; a thrown summarize call emits `Compaction failed — context unchanged` (auto path swallows the error, manual path rethrows after emitting). Previously the preflight fired first and `selfCompactSession`'s silent `return null` paths left an orphan "Compacting context…" with no outcome — see `memory/2026-08-04-compact-preflight-orphan.md` for the production forensics.
429
+ **Notification contract — a "Compacting context…" preflight is only announced when compaction will actually run, and always gets an outcome line.** Both self-compact entry points (`maybeAutoCompact`, manual `compactSession`) check `compactCut() === 0` *before* emitting the preflight and bail silently when there's nothing to compact. After the preflight, every path closes it out: success pairs it with `Auto-compacted N older messages` / `Context compacted: …`; an empty LLM summary (a thinking-heavy model can legally spend its entire response on non-text blocks) emits `Compaction skipped — no summary produced` on the manual path; a thrown summarize call emits `Compaction failed — context unchanged` and the manual path rethrows after emitting; the auto path closes both with the `Auto-compacted … (local fallback …)` notice above; a cancelled manual compact emits `Compact cancelled`. Previously the preflight fired first and `selfCompactSession`'s silent `return null` paths left an orphan "Compacting context…" with no outcome — see `memory/2026-08-04-compact-preflight-orphan.md` for the production forensics.
418
430
 
419
431
  **Post-compact token estimate.** Every compact ends with `estimateMessageTokens(messages) + estimateMessageTokens(systemPrompt)` written to `session.lastContextTokens` — the number the 80% gate compares against until the next real `usage` event arrives. It is a heuristic (`chars / 3.5` for mixed CJK/English), and it counts every block type: `text` by length, `tool_use` by `name + JSON(input)`, `tool_result` by its string or nested text blocks, and each image — top-level or nested in a `tool_result` — a flat **1500 tokens** (Anthropic's `w*h/750` rule caps near 1600 at max size; we don't decode dimensions). A text-only count under-read image- and tool-heavy histories and pushed the next auto-compact out until the model itself rejected the payload.
420
432
 
421
433
  **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.
422
434
 
423
- Config (see `config.compact`): `keepMessages` / `maxSummaryInput` / `maxMessageSlice` / `summarizeTimeoutSec` — editable in Settings → General → compact.
435
+ Config (see `config.compact`): `keepMessages` / `maxSummaryInput` / `maxMessageSlice` — editable in Settings → General → compact.
424
436
 
425
437
  **Self-compact also archives the UI log.** `selfCompactSession` calls `uiStore.archiveOldMessages(session.id)` for **root sessions only** (`!session.parentId`), 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).
426
438
 
@@ -458,7 +470,7 @@ Accepted edge, documented so it isn't "fixed": `rawMessages` shares the active f
458
470
 
459
471
  1. **Segment first, active file second.** `writeArchiveSegment(n)` writes a path nothing references yet; only then is the active file rewritten with the kept exchange. The reverse order could drop messages before their archive exists.
460
472
  2. **`archiveCount` in the active file is the commit marker.** A segment with `N > archiveCount` was never committed and no reader may reference it (both the WS anchor and the HTTP route check this). So a crash between the two steps leaves an unreachable orphan plus an active file that still holds every message — nothing lost. The next compact re-derives the same `N` and overwrites the orphan, which is why there's no reconciliation pass.
461
- 3. **Read-back verify, else roll memory back.** `persistUIState` swallows IO errors like every session write, so `archiveOldMessages` re-reads `archiveCount` afterwards; if it is `< n` the truncated in-memory `messageLog` is restored from the two halves. Without that, the *next* ordinary persist would write the short log under the old count — that, not the crash, is how messages would actually go missing.
473
+ 3. **Read-back verify, else roll memory back.** `persistLog` swallows IO errors like every session write, so `archiveOldMessages` re-reads `archiveCount` afterwards; if it is `< n` the truncated in-memory `messageLog` is restored from the two halves. Without that, the *next* ordinary persist would write the short log under the old count — that, not the crash, is how messages would actually go missing.
462
474
 
463
475
  Segments are immutable and append-only (`N` from 1, monotonically). Deletion is filesystem-driven: `deleteArchiveSegments()` globs `<seg>.arch.*.json.gz` in the directory rather than trusting `archiveCount` or any in-memory map, so crash-orphaned segments go too. Both session-file delete paths (`deleteSessionFile`, `findAndDeleteSessionFile`) call it.
464
476
 
@@ -103,7 +103,7 @@ Unlike Telegram (grammy polling) or WeChat (HTTP long-poll), Socket Mode elimina
103
103
  7. Strip the `<@botUserId>` mention prefix from the text
104
104
  8. (DMs only) Check if text is a slash command (starts with `/` or `!`); if so, dispatch via `channels/shared/commands.ts` and return early
105
105
  9. Download and ingest any attached files (save images + files to workspace media store; images base64'd for vision)
106
- 10. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)): resolve or create the session for this {channel, thread}, apply the goal-mode route overlay, send a busy hint if needed, refresh the reply route `{channelId, replyTs}`, attach the `SlackResponder` listener once (torn down on `stopAccount` via `bridge.closeAll()`), then queue the user message with SessionManager
106
+ 10. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)): resolve or create the session for this {channel, thread}, send a busy hint if needed, refresh the reply route `{channelId, replyTs}`, attach the `SlackResponder` listener once (torn down on `stopAccount` via `bridge.closeAll()`), then queue the user message with SessionManager
107
107
 
108
108
  ### Outbound (SlackResponder)
109
109
 
@@ -203,7 +203,7 @@ Text, images (base64 attached to agent input for vision), files. All files are d
203
203
 
204
204
  The agent runtime produces `MEDIA: <path>` markers. SlackResponder:
205
205
  - Intercepts the markers and calls `uploadFile()` for each one
206
- - Blocks sandbox violation (files must be under workspace or /tmp)
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
207
207
  - Falls back to error text if upload fails
208
208
 
209
209
  Outbound media uses Slack's v2 upload flow (getUploadURLExternal → signed PUT → completeUploadExternal).
@@ -50,7 +50,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
50
50
  ├── sessions/<agentId>/ # Session files (one per regular session)
51
51
  ├── memory/ # Project memory (dated entries)
52
52
  ├── canvas/self.html # Agent's visual face — platform-owned, force-copied on every open (design/express-self.md)
53
- ├── goal/<goalId>/ # Goal mode: GOAL_SPEC.md + decision-<n>.md (design/goal-mode.md)
53
+ ├── goal/<goalId>/ # Retained legacy goal-mode artifacts (GOAL_SPEC.md + decision-<n>.md); existing goals unchanged
54
54
  ├── logs/
55
55
  ├── evo/ # Self-evolution per-workspace artifacts
56
56
  │ ├── runs/<id>/ # per-evaluation: source-snapshot.json, tool-flow.md,
@@ -92,7 +92,7 @@ Protocol (single-machine scope; NFS / cross-host explicitly out):
92
92
  - **Claim** = `O_EXCL` create with own pid; first claimer owns the runtime.
93
93
  - **EEXIST** → read the holder pid and probe with `kill(pid, 0)`. Holder alive (`EPERM` counts as alive — false-dead is the dangerous direction) → return false, caller skips reconcile. Holder is own pid → already owned.
94
94
  - **Dead / unreadable holder** → stale takeover via write-tmp + atomic rename (keeps the path existent at all times, so a third comer always sees *some* holder).
95
- - **No explicit release**: pid liveness *is* the release — graceful exit and SIGKILL converge on the same "pid dead → next boot takes over" path, so there is no exit hook to forget or crash past.
95
+ - **Release on graceful exit**: `gracefulShutdown` calls `releaseWorkspaceRuntime` per workspace, which unlinks the file only when it holds *our own* pid (a file another live process owns or took over is left alone). A crash / SIGKILL leaves the file and converges on the "pid dead → next boot takes over" path, so correctness never depends on the exit hook having run — the release only keeps a reboot-recycled pid from reading as a live holder.
96
96
 
97
97
  Bias is deliberate: pid reuse can read a stale pid as "alive," which only costs one missed cleanup round; a false-dead could stop another process's live sessions. Full race analysis in the file's header comment.
98
98
 
@@ -209,6 +209,8 @@ type ContentBlockEntry =
209
209
 
210
210
  `turnId` uniquely identifies each LLM API call. Content blocks and the corresponding usage message in the same turn share the same turnId.
211
211
 
212
+ The `usage` event rotates `turnId` on both root and sub-session turn state without flushing the assistant message: the agent loop yields `tool_call`s before `usage` and runs the tools after, so at usage time every tool call of that model call is still pending — a flush there would split thinking/text from the tool_calls into two assistant messages (the pre-fix sub-session behavior). One assistant message therefore spans all model calls of a turn, each call's blocks tagged with its own turnId; it reaches disk at `complete` / `agent_done`, the next `user` or `system` event, or the interrupt flush. Root and sub-session logs persist by one rule: `persistLog` overwrites the session's own file whole from its UI log, and every save carries the in-flight turn as a temporary assistant message (`createSaveSnapshot`). A sub-session log starts from its own file (`loadSubHistory`), so it always holds the full history; writes are debounced 500ms per session id (tool_call / tool_result / usage / context / user / system / error) and flushed at turn end (root `complete`, sub `agent_done`) and on interrupt.
213
+
212
214
  ### Render rules
213
215
 
214
216
  | Mode | Visible | Hidden |
@@ -332,7 +334,6 @@ general: # built-in declarer (the server itself
332
334
  keep_messages: 5
333
335
  max_summary_input: 15000
334
336
  max_message_slice: 800
335
- summarize_timeout_sec: 300
336
337
  sandbox:
337
338
  hidden_dirs: "~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh"
338
339
  hidden_files: "~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/evo.db,~/.halo/global/evo.db-wal,~/.halo/global/evo.db-shm,~/.halo/global/cron.db,~/.halo/global/cron.db-wal,~/.halo/global/cron.db-shm,~/.halo/global/runs.db,~/.halo/global/runs.db-wal,~/.halo/global/runs.db-shm"
@@ -382,7 +383,6 @@ A value of the form `<<ENV_NAME>>` is replaced at read time with `process.env.EN
382
383
  | `general.compact.keep_messages` | 5 | Recent messages kept intact during compaction |
383
384
  | `general.compact.max_summary_input` | 15000 | Local truncation fallback total char cap |
384
385
  | `general.compact.max_message_slice` | 800 | Local truncation per-message char cap |
385
- | `general.compact.summarize_timeout_sec` | 300 | LLM summary timeout |
386
386
  | `general.limits.shell_output_bytes` | 5242880 | Max bytes captured from one `shell_exec` (stdout+stderr); excess truncated with a `[truncated]` marker |
387
387
  | `general.limits.web_fetch_bytes` | 51200 | Max bytes downloaded by one `web_fetch` |
388
388
  | `general.limits.grep_default_matches` | 50 | Default `grep` match cap when no explicit `max` is passed |
@@ -576,7 +576,7 @@ The databases hold session metadata indexes and workspace-scoped preferences (e.
576
576
  | created_at / updated_at | INTEGER | Unix ms |
577
577
  | stopped_at | INTEGER | Stopped timestamp (null = active) |
578
578
  | archived_at | INTEGER | Archived timestamp (null = not archived) |
579
- | goal / goal_session_id | TEXT | Goal mode — binding JSON on the goal session's row, back-pointer on the worker's |
579
+ | goal / goal_session_id | TEXT | Retained legacy columns (goal mode binding JSON / back-pointer); existing bindings are unchanged and still honored |
580
580
  | reply_to | TEXT | Relay — JSON `{ workspace, sessionId }` of the caller session in another workspace; set by `relay_send`, cleared on report delivery. Added in `HALO_MIGRATIONS` slot (`db/index.ts`). |
581
581
  | title | TEXT | Mirror of the session file's `title` |
582
582
  | exchange_count | INTEGER | Mirror: main user turns over the session's lifetime (kept + archived) |
@@ -22,7 +22,7 @@ All four IM channels (telegram / wechat / slack / feishu) route inbound messages
22
22
  Three pieces:
23
23
 
24
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.
25
- - **`deliverInbound`** — the tail itself: `rememberLastActiveChat` → access-level projection → resolve-or-create the channel session → `resolveGoalRoute` overlay → busy/compacting hint (hint only; delivery continues regardless) → `setRoute` → `ensureListener` → `appendUserMessage(uiText)` + `sendUserMessage("[channel: X | user: Y | thread: Z]\n" + agentText)`. Returns the (goal-routed) session id.
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
 
28
28
  Deliberately **not** shared (real semantic differences, not drift): message parsing, media download / ingest, slash-command gating (slack `!cmd` alias + DM-only, feishu p2p-only, telegram's `bot.command` registration, wechat `/qr`), the send primitives (thread routing, passive reply window), and post-command effects (workspace restart, wechat's `switchTo` listener migration).
@@ -98,7 +98,7 @@ Routes: `packages/server/src/routes/telegram.ts`
98
98
  1. Check user against `allowedUsers` whitelist
99
99
  2. Resolve workspace (check path exists on disk)
100
100
  3. If the text is a registered command, `runCommand` (→ `dispatchChannelCommand`) and stop
101
- 4. Otherwise `handleUserMessage` → `deliverInbound` ([shared skeleton](#shared-inbound-skeleton)): session resolve/create → goal route → busy hint → route `{chatId}` + `TelegramResponder` listener → deliver
101
+ 4. Otherwise `handleUserMessage` → `deliverInbound` ([shared skeleton](#shared-inbound-skeleton)): session resolve/create → busy hint → route `{chatId}` + `TelegramResponder` listener → deliver
102
102
 
103
103
  Photos / documents / voice / video notes trigger the same flow with the saved-file note in the text.
104
104
 
@@ -138,6 +138,8 @@ Outbound: the `send-file` skill produces `MEDIA:<path>` markers. The responder s
138
138
  - `.ogg/.oga` → `sendVoice`
139
139
  - anything else → `sendDocument`
140
140
 
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.
142
+
141
143
  ## Configuration
142
144
 
143
145
  ### BotFather setup
@@ -13,7 +13,7 @@ Halo server (9527) ──┤── channels/telegram/ ├── Sess
13
13
  ilinkai.weixin.qq.com
14
14
  ```
15
15
 
16
- All channels are peers — each one is a subscriber + caller against SessionManager. Common slash commands (`/help`, `/evo`, and the object commands `/session`, `/agent`, `/skill`, `/workspace`) live in `channels/shared/commands.ts`; each channel handler is a thin adapter that builds a `CommandContext` and formats the result for its transport. The inbound tail (session resolve → goal route → busy hint → responder wiring → deliver) is likewise shared — see [Shared inbound skeleton](telegram.md#shared-inbound-skeleton).
16
+ All channels are peers — each one is a subscriber + caller against SessionManager. Common slash commands (`/help`, `/evo`, and the object commands `/session`, `/agent`, `/skill`, `/workspace`) live in `channels/shared/commands.ts`; each channel handler is a thin adapter that builds a `CommandContext` and formats the result for its transport. The inbound tail (session resolve → busy hint → responder wiring → deliver) is likewise shared — see [Shared inbound skeleton](telegram.md#shared-inbound-skeleton).
17
17
 
18
18
  ## Data model
19
19
 
@@ -46,11 +46,12 @@ Sub-sessions inherit the parent's access level — a readonly channel can't dele
46
46
 
47
47
  WeChat bot accounts are stored in the unified channel DB: `~/.halo/secrets/channels/channels.db`, table `channel_accounts` with `channel_type = 'wechat'`. See [storage.md](storage.md#channel_accounts) for the full schema.
48
48
 
49
- WeChat-specific config JSON fields: `botToken`, `baseUrl`, `userId`, `syncBuf`, `lastActiveChatId`, `contextTokens`.
49
+ WeChat-specific config JSON fields: `botToken`, `baseUrl`, `userId`, `syncBuf`, `lastActiveChatId`, `contextTokens`, `contextTokenAts`.
50
50
 
51
51
  - `userId` is the QR-bind owner (the ilink_user_id of whoever scanned the QR code to register this bot). Used by the wechat cron-dispatcher as the default proactive-send target — sending here means cron output goes back to the bot's owner, which is the common-case "report to me on a schedule" intent.
52
52
  - `lastActiveChatId` is a runtime cache of the most recent inbound `from_user_id`. Written by `rememberLastActiveChat()` in `channels/shared/accounts.ts` with a per-process hash so unchanged values never touch the db (idempotent — hot path stays in memory). Used as a fallback for cron sends in the shared-bot case where you want to reply to whoever talked last.
53
53
  - `contextTokens` is a `Record<userId, context_token>` — the most recent inbound `context_token` per user, written by `rememberWechatContextToken()` (same in-process dedupe / write-on-change shape as `lastActiveChatId`). The ilink gateway expects every outbound `sendmessage` to echo the recipient's latest inbound token; the chat-reply path already carries it in the in-memory bridge route, but cron dispatch runs long after the inbound and needs a persisted copy.
54
+ - `contextTokenAts` is the parallel `Record<userId, epochMs>` of when each token arrived, written in the same `patchConfig` call. The token also **expires**: the gateway rejects a stale one with the same `ret=-2`, and only a new inbound message from that user refreshes it (window not fixed — observed rejected at 12 min and 79 min after the last inbound, accepted at 68 min). The stamp travels with the token (`WxRoute.contextTokenAt` in memory, replayed by `restoreReplyRoutes` after a restart, read by the cron dispatcher) purely so a `ret=-2` can say `context_token age 79m` vs `context_token missing`; accounts whose token predates the stamp report `age unknown`.
54
55
 
55
56
  ### Proactive sending (cron)
56
57
 
@@ -62,7 +63,7 @@ The wechat cron-dispatcher (`channels/wechat/cron-dispatcher.ts`) registers itse
62
63
 
63
64
  WeChat is single-recipient per dispatch (no fan-out across `allowedUsers` like telegram has). If none of the three yields an id, dispatch fails with a clear "no wechat target — bind the account first" message.
64
65
 
65
- The text is chunked exactly like a chat reply — `splitText(text, WECHAT_TEXT_LIMIT)` (3500 chars, see [Event coalescing](#event-coalescing-wechatresponder)) — and the chunks are sent sequentially so a long report arrives in order. Before this, a whole report went out in one `sendmessage` call and anything over the gateway's 16 KB ceiling failed outright with `ret=-2 "prepare failed"` (the stock-report job hit it routinely). A mid-chunk failure rethrows as `chunk i/n: <error>`, so the run row's dispatch result shows how much of the report already landed (chunks before `i` were delivered). Every chunk carries the recipient's persisted `context_token` (`config.contextTokens[chatId]`, see above) — the ilink gateway expects outbound to echo the latest inbound token, and sending without it was the second `ret=-2` cause: 6 failures in 10 days on payloads far under 16 KB, one of them 6 minutes after the user had just been chatting with the bot, which rules out the earlier "no recent inbound message" theory. Accounts with no inbound since 1.1.8 have no token yet and still send bare (as before) until the owner's next message. `api.ts`'s `ret=-2` hint names both causes (oversized payload / missing token).
66
+ The text is chunked exactly like a chat reply — `splitText(text, WECHAT_TEXT_LIMIT)` (3500 chars, see [Event coalescing](#event-coalescing-wechatresponder)) — and the chunks are sent sequentially so a long report arrives in order. Before this, a whole report went out in one `sendmessage` call and anything over the gateway's 16 KB ceiling failed outright with `ret=-2 "prepare failed"` (the stock-report job hit it routinely). A mid-chunk failure rethrows as `chunk i/n: <error>`, so the run row's dispatch result shows how much of the report already landed (chunks before `i` were delivered). Every chunk carries the recipient's persisted `context_token` (`config.contextTokens[chatId]`, see above) — the ilink gateway expects outbound to echo the latest inbound token, and sending without it was the second `ret=-2` cause: 6 failures in 10 days on payloads far under 16 KB, one of them 6 minutes after the user had just been chatting with the bot, which rules out the earlier "no recent inbound message" theory. Accounts with no inbound since 1.1.8 have no token yet and still send bare (as before) until the owner's next message. The third `ret=-2` cause is an **expired token** (see `contextTokenAts` above) — a push that echoes a token from an old inbound is rejected once the gateway's validity window has passed, and nothing halo does refreshes it; the recipient has to message the bot again. `api.ts`'s `ret=-2` hint names all three causes (oversized payload / no token / expired token) and appends the echoed token's age (`context_token age 79m` · `context_token missing` · `context_token age unknown`), also written as a `[WeChat:sendmessage] ret=-2 …` warn line.
66
67
 
67
68
  ## Modules
68
69
 
@@ -101,7 +102,7 @@ loop {
101
102
  1. Extract `from_user_id` / text + media items / `context_token`
102
103
  2. Process inbound media: images → base64 attached to the agent message; voice / video / files → saved under the workspace with a `[voice saved: ...]` etc. marker appended to the text
103
104
  3. If the text begins with `/`, dispatch a slash command (see below) — if it returns `handled`, stop here
104
- 4. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)), which caches `lastActiveChatId`, resolves-or-creates the session, applies the goal-mode route overlay, sends a busy/compacting hint when needed, refreshes the reply route (`wxRoute(fromUserId, context_token)`), attaches the `WechatResponder` listener once, and delivers: clean text to the UI log, `[channel: wechat | user: <id>]\n<text>` to the agent (so it knows how to address the media-send skill)
105
+ 4. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)), which caches `lastActiveChatId`, resolves-or-creates the session, sends a busy/compacting hint when needed, refreshes the reply route (`wxRoute(fromUserId, context_token)`), attaches the `WechatResponder` listener once, and delivers: clean text to the UI log, `[channel: wechat | user: <id>]\n<text>` to the agent (so it knows how to address the media-send skill)
105
106
 
106
107
  ## Slash commands
107
108
 
@@ -127,7 +128,8 @@ WeChat `sendMessage` is block-send, while LLMs stream. The current strategy is "
127
128
  - Every chunk goes through one serialized send chain per responder (`sendTail`, same as Slack / Feishu) so a long reply arrives in order; `close()` returns the drain promise and `InboundBridge` keeps the reply route alive until it settles
128
129
  - `error` → immediate flush + send a `[错误] …` message; `system` → immediate flush + send a `[系统] …` message
129
130
  - Tool calls / tool results / thinking are dropped (detail lives in the web UI)
130
- - Media: the agent emits `MEDIA: <path>` markers, which the responder extracts and turns into actual media uploads. The path must be under the account's workspace or the OS temp dir (`/tmp`) — `isMediaPathAllowed`; anything else (e.g. another workspace's files) is dropped with a `[WeChat] sendMedia blocked` warn log and nothing is sent
131
+ - Media: the agent emits `MEDIA: <path>` markers, which the responder extracts and turns into actual media uploads. A `full` account may send any readable path (its shell / file tools are unrestricted anyway); other access levels are limited to the account's workspace or the OS temp dir (`/tmp`) — `isMediaPathAllowed(path, workspace, accessLevel)`. A blocked path (e.g. another workspace's files) throws inside `sendMedia`, so it rides the responder's `onSendError` path and lands in the session log as a `⚠️ WeChat delivery failed: sendMedia … media path not allowed` notification instead of being dropped silently
132
+ - Send failures are surfaced, never retried: when `sendText` / `sendMedia` rejects (typically the gateway's `ret=-2`), `dispatchChunk` logs it and calls the optional `onSendError(message)` dep; the handler wires that to `sm.appendNotification(sessionId, '⚠️ WeChat delivery failed: …')`, so the session log shows the turn ran but its reply never reached WeChat. `appendNotification` only appends a `notification` row and persists — it emits no event, which matters: a `system` event would loop back into this responder's `case 'system'` and try to send to WeChat again
131
133
 
132
134
  **The responder never captures its recipient.** Both `sendText` and `sendMedia` read `bridge.getRoute(sessionId)` at send time, so a `{fromUserId, contextToken}` refreshed by a later inbound message takes effect immediately. Closing over `fromUserId` at listener-registration time was the A-M2 bug: after a full-access `/session switch` moved a session to another user, the listener kept replying to the *first* one. `contextToken` (WeChat's passive-reply-window credential) is carried over between messages **only while the user is the same** — a new user's route starts without the old token rather than replaying it against the new recipient. `/session switch` additionally calls `bridge.dropListener(oldSid)` before wiring the target so the abandoned session doesn't keep a live responder.
133
135
 
@@ -133,7 +133,7 @@ All frames are JSON `{ cmd, headers: { req_id }, body }`; responses echo `header
133
133
  5. Group text: strip leading `@token` mentions (`/^(@\S+\s*)+/`). WeCom only delivers group messages that `@` the bot, so there is no `shouldRespond` check. A multi-word bot name leaves its tail as residue — preferable to guessing the name and eating the user's first word
134
134
  6. Slash command dispatch — **single chat only** (a group's shared session belongs to everyone). `/workspace switch` persists the new path on the account row; the reply goes out via `replyStream`
135
135
  7. Skip if neither text/notes nor images remain
136
- 8. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)): create or retrieve the session (with inherited access level) + goal-mode overlay; busy / compacting hint via `replyStream`; refresh the route and attach the `WecomResponder` listener once; `sm.sendUserMessage(sessionId, agentInput, images?)` with `[channel: wecom | user: <userid>]` (+ ` | thread: <chatid>` in groups)
136
+ 8. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)): create or retrieve the session (with inherited access level); busy / compacting hint via `replyStream`; refresh the route and attach the `WecomResponder` listener once; `sm.sendUserMessage(sessionId, agentInput, images?)` with `[channel: wecom | user: <userid>]` (+ ` | thread: <chatid>` in groups)
137
137
 
138
138
  ## Event coalescing (WecomResponder)
139
139
 
@@ -153,7 +153,7 @@ Chunk sends are serialized per responder (`sendTail` promise chain, audit A-L3)
153
153
 
154
154
  **Inbound:** text, voice (as transcript), images (vision + saved), mixed (text + images), files, videos — single chat only for everything but text. Saved under `<workspace>/.halo/assets/wecom/inbound/<accountId>/<date>/`.
155
155
 
156
- **Outbound routing** (`sendWecomMedia`, path must be under the workspace — `isMediaPathAllowed`):
156
+ **Outbound routing** (`sendWecomMedia`; for non-`full` accounts the path must be under the workspace or the OS temp dir — `isMediaPathAllowed(path, workspace, accessLevel)`, `full` accounts may send any readable path; a blocked path surfaces as the same `t('handler.upload_failed')` reply as a failed upload):
157
157
  - `.png` / `.jpg` / `.jpeg` / `.gif` → `uploadMedia(type: 'image')` → `replyMedia('image')`
158
158
  - `.mp4` → `uploadMedia(type: 'video')` → `replyMedia('video')`
159
159
  - everything else (`.webp` / `.bmp` / `.mov` / documents / audio) → `uploadMedia(type: 'file')` → `replyMedia('file')` — WeCom's `image` accepts png/jpg/gif only and `video` mp4 only