@turmind/halo 1.5.10 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +2 -2
  2. package/admin-out/404.html +1 -1
  3. package/admin-out/_next/static/chunks/{388-d5950f264d91625a.js → 147-d4200c70445aabb6.js} +1 -1
  4. package/admin-out/_next/static/chunks/989-a7f752480eaaa2dc.js +1 -0
  5. package/admin-out/_next/static/chunks/app/{layout-5ec6c5f713a8562b.js → layout-ea161287d934ba96.js} +1 -1
  6. package/admin-out/_next/static/chunks/app/page-8173112f256bc483.js +1 -0
  7. package/admin-out/_next/static/chunks/{webpack-efd27999305b080a.js → webpack-dc73047e0279979b.js} +1 -1
  8. package/admin-out/_next/static/css/7b736202f0b1dbfd.css +1 -0
  9. package/admin-out/_next/static/css/981c9e5610ce535a.css +1 -0
  10. package/admin-out/index.html +1 -1
  11. package/admin-out/index.txt +6 -5
  12. package/bundled-docs/design/a2a.md +286 -0
  13. package/bundled-docs/design/agent.md +7 -5
  14. package/bundled-docs/design/agentcore.md +194 -122
  15. package/bundled-docs/design/architecture.md +2 -2
  16. package/bundled-docs/design/canvas-extensions.md +30 -9
  17. package/bundled-docs/design/halo-city.md +6 -0
  18. package/bundled-docs/design/observability.md +12 -4
  19. package/bundled-docs/design/prompt-system.md +1 -1
  20. package/bundled-docs/design/relay.md +6 -6
  21. package/bundled-docs/design/session.md +15 -12
  22. package/bundled-docs/design/storage.md +9 -2
  23. package/bundled-docs/design/web.md +36 -59
  24. package/bundled-docs/dev/acp-adapter.md +59 -175
  25. package/bundled-docs/dev/api.md +75 -6
  26. package/bundled-docs/dev/deploy.md +3 -2
  27. package/bundled-docs/dev/desktop-packaging.md +14 -0
  28. package/bundled-docs/dev/env.md +10 -6
  29. package/bundled-docs/dev/tools.md +101 -11
  30. package/bundled-docs/guide/README.md +1 -1
  31. package/bundled-docs/guide/channels/README.md +3 -3
  32. package/bundled-docs/guide/channels/acp.md +67 -42
  33. package/bundled-docs/guide/channels/feishu.md +1 -1
  34. package/bundled-docs/guide/channels/slack.md +1 -1
  35. package/bundled-docs/guide/channels/telegram.md +1 -1
  36. package/bundled-docs/guide/channels/web.md +10 -34
  37. package/bundled-docs/guide/channels/wechat.md +1 -1
  38. package/bundled-docs/guide/channels/wecom.md +1 -1
  39. package/bundled-docs/guide/chat.md +0 -1
  40. package/bundled-docs/guide/cli.md +41 -6
  41. package/bundled-docs/guide/delegation-and-access.md +3 -2
  42. package/bundled-docs/guide/getting-started.md +1 -1
  43. package/bundled-docs/guide/skills.md +2 -3
  44. package/bundled-docs/requirements/agent.md +1 -1
  45. package/bundled-docs/requirements/chat.md +17 -1
  46. package/bundled-docs/requirements/command.md +1 -2
  47. package/bundled-docs/requirements/editor.md +9 -3
  48. package/bundled-docs/requirements/explorer.md +1 -0
  49. package/bundled-docs/requirements/terminal.md +15 -0
  50. package/bundled-docs/requirements/workspace.md +6 -0
  51. package/dist/index.js +5392 -2786
  52. package/package.json +1 -1
  53. package/templates/agents/__score__/AGENT.md +1 -1
  54. package/templates/agents/default/agent.yaml +0 -1
  55. package/templates/models/aws-bedrock-claude-invoke.yaml +40 -1
  56. package/templates/models/aws-bedrock-openai.yaml +100 -6
  57. package/templates/prompts/all/RUNTIME.md +2 -1
  58. package/templates/prompts/all/TOOL_SHELL.windows.md +2 -1
  59. package/templates/skills/send-file/SKILL.md +1 -1
  60. package/templates/skills/send-file/a2a.md +13 -0
  61. package/templates/skills/skill/SKILL.md +1 -1
  62. package/templates/skills/workspace/stage.py +1 -1
  63. package/admin-out/_next/static/chunks/989-1797287c46139412.js +0 -1
  64. package/admin-out/_next/static/chunks/app/page-44ab337b2f071e4d.js +0 -1
  65. package/admin-out/_next/static/css/d53aa54212002364.css +0 -3
  66. package/templates/skills/acp/SKILL.md +0 -243
  67. package/templates/skills/acp/templates/SKILL.md.tmpl +0 -75
  68. package/templates/skills/acp/templates/ask.py +0 -177
  69. package/templates/skills/acp/templates/config.yaml.tmpl +0 -38
  70. /package/admin-out/_next/static/{A-Rjx2yltZDKaSO1qVv7K → _F_Fkyh9PainUNY82EEjY}/_buildManifest.js +0 -0
  71. /package/admin-out/_next/static/{A-Rjx2yltZDKaSO1qVv7K → _F_Fkyh9PainUNY82EEjY}/_ssgManifest.js +0 -0
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Problem
6
6
 
7
- Halo runs many workspaces on one server — one per team / project / credential boundary — and each is a self-contained agent runtime. A "secretary" pattern falls out naturally: one workspace whose agent only knows *which department knows what* and forwards the user's question there. Before relay the only cross-workspace path was the ACP adapter (HTTP + SSE + web-channel tokens, and an `ask-<label>` binding per remote), which is the right tool for a *different server* but heavy for two sqlite files on the same box. Relay is the in-process version: no HTTP, no tokens, no polling — and the result comes back on its own.
7
+ Halo runs many workspaces on one server — one per team / project / credential boundary — and each is a self-contained agent runtime. A "secretary" pattern falls out naturally: one workspace whose agent only knows *which department knows what* and forwards the user's question there. Before relay the only cross-workspace path was a remote call over HTTP + SSE + web-channel tokens, which is heavy for two sqlite files on the same box. Relay is the in-process version: no HTTP, no tokens, no polling — and the result comes back on its own.
8
8
 
9
9
  ## Mechanism
10
10
 
@@ -32,8 +32,8 @@ Relay rides the same "deliver a session's wrap-up to some *other* session when i
32
32
  - **Name-gated bundle.** Only `relay_send` is recognised in `tools:`; the other four (`relay_interrupt` / `relay_stop` / `relay_read` / `relay_list`) ride along. Prevents a half-configured agent that can send but never read back / stop. The admin picker mirrors this with one chip (`GET /agent-configs/tools` builds it from `buildRelayTools` against a dummy target and rewrites the description to name the full set).
33
33
  - **Server only.** `setRelayRegistry` is called once in `index.ts`; the CLI / TUI never call it, so `getRelayRegistry()` is `null` there — every tool returns `{"code": 1, "error": RELAY_UNAVAILABLE}` (`relay.ts`: "relay is not available in the CLI / TUI — it only runs inside halo server", permanent for this runtime, do not retry; send from the admin UI / an IM or Web channel, or open the target with `halo tui -w <workspace path>`) and `deliverRelayReport` logs a warning and returns. No CLI code path knows relay exists. Note the builder does **not** check the registry before injecting, so a CLI / TUI session (and every cron run, which is a `halo cli` child) still *sees* the relay tools when its agent lists `relay_send` — the error is permanent there, not a transient outage; route cross-workspace dispatch through a server-side channel (admin / Web / IM / `halo acp`) instead.
34
34
  - **Target-side transcript is normal.** The department workspace's session looks like any other root session in its Sessions tab (user message, agent reply); the only trace of relay is the `reply_to` row while a dispatch is pending. Nothing in the target's system prompt changes.
35
- - **Same workspace is allowed.** `workspace` is only checked for existence + `.halo/`; passing the caller's own path makes `getOrCreate` return the caller's own `SessionManager`, and everything else is unchanged. In effect relay is a `query_session` without the own-tree scoping — it reaches any **root** session in any workspace, including new ones. Not a hole: the tool is full-access only, and a full-access agent could already `shell_exec` its way into any session file.
36
- - **Reports come back from roots only.** `deliverRelayReport` fires for `parentId === null`; a sub-session's turn end goes to `tryReportToParent` instead. Dispatching to a `parent>child` id delivers the message but never a report — target roots when you want to hear back. Dispatching to yourself works and terminates (one self-report, `reply_to` cleared) but is pointless.
35
+ - **Same workspace is allowed — except self and ancestors.** `workspace` is only checked for existence + `.halo/`; passing the caller's own path makes `getOrCreate` return the caller's own `SessionManager`, and everything else is unchanged, with one exception: there `relay_send` / `relay_interrupt` / `relay_stop` refuse a `session_id` that is the caller itself or one of its ancestors (`isSelfOrAncestor` from `session-tools.ts`; the tools compare the resolved manager with their own host, so the same id in another workspace is unaffected). A send there stamped `reply_to` with a session inside the target's own tree — a self-report, or an ancestor whose report goes back to its own descendant — and a stop awaited the very turn making the call. In effect relay is a `query_session` without the own-tree scoping — it reaches any **root** session in any workspace, including new ones. Not a hole: the tool is full-access only, and a full-access agent could already `shell_exec` its way into any session file.
36
+ - **Reports come back from roots only.** `deliverRelayReport` fires for `parentId === null`; a sub-session's turn end goes to `tryReportToParent` instead. Dispatching to a `parent>child` id delivers the message but never a report — target roots when you want to hear back. Dispatching to yourself is refused (see above).
37
37
 
38
38
  ## Interim report (continue_task)
39
39
 
@@ -57,11 +57,11 @@ No quiet gate (the session is by definition not done) and **`reply_to` is kept**
57
57
 
58
58
  ## Soft vs hard interrupt
59
59
 
60
- `relay_send` to a busy target = queued + soft interrupt (the current tool call finishes, then the message drains as part of one merged turn). That covers follow-ups and corrections — the secretary's default. `relay_interrupt` is for "stop what you're doing *now*": it aborts the in-flight turn (propagates to `shell_exec`, SIGTERMs the process group), then the queued message runs. Idle target → `relay_interrupt` degrades to a plain send (`interrupted: false`). The distinction is the same as `query_session` vs `interrupt_session` inside one workspace; relay just carries it across.
60
+ `relay_send` to a busy target = queued + soft interrupt (the current batch of tool calls finishes, then the message drains as part of one merged turn). That covers follow-ups and corrections — the secretary's default. `relay_interrupt` is for "stop what you're doing *now*": it aborts the in-flight turn (propagates to `shell_exec`, SIGTERMs the process group), then the queued message runs. Idle target → `relay_interrupt` degrades to a plain send (`interrupted: false`). The distinction is the same as `query_session` vs `interrupt_session` inside one workspace; relay just carries it across.
61
61
 
62
62
  ## What it deliberately doesn't do
63
63
 
64
- - **No cross-server reach.** Same `SessionManagerRegistry`, same process. Different server → ACP adapter (`/acp add`).
64
+ - **No cross-server reach.** Same `SessionManagerRegistry`, same process. Different server → [A2A](a2a.md), which reuses the same `reply_to` column (`{ a2a: taskId }`), quiet gate and interim doors.
65
65
  - **No session discovery beyond a flat root list.** `relay_list` returns a workspace's root sessions (id / agent / title / status, newest 100) so the secretary can reuse an existing conversation; it doesn't walk trees or search transcripts — that's the admin's job.
66
66
  - **No broadcast / fan-out helper.** The secretary calls `relay_send` once per department; the reports come back individually with the workspace + session id in the header, so it can tell them apart without extra bookkeeping.
67
67
  - **No ACL beyond full access.** A full-access secretary can reach every workspace on the box — that's the deployment's trust boundary already (it can `shell_exec` into them anyway).
@@ -69,4 +69,4 @@ No quiet gate (the session is by definition not done) and **`reply_to` is kept**
69
69
 
70
70
  ## Test
71
71
 
72
- `packages/server/test/relay.test.ts` — stub `RelayTarget` + registry, 15 cases: `deliverRelayReport` no-op without `reply_to`, waits while a child is active, delivers exactly one append + one send with the header then clears, `ABORTED` prefix on `turnError` (and the account-error variant: "fix the model config" instead of the re-send hint), ignores sub-sessions; `relay_send` creates + stamps `reply_to` + channel-prefixes the model text, rejects a nonexistent workspace, returns the permanent error without a registry (CLI / TUI); `relay_interrupt` busy → enqueue then abort + re-stamp, idle → no abort and never creates; `relay_list` roots only with title fallback, and defaults to the caller's own workspace. `deliverRelayInterim`: no-op without `reply_to`; delivers the interim header and keeps `reply_to`, the later final report still fires once. End-to-end through `drainQueue` / `runSession` (relay interim → final, no kick → no interim, local message → no interim, opening-turn and deferred interims incl. the local-opening-message and quiet-subtree negatives) lives in `test/continue-task.test.ts`.
72
+ `packages/server/test/relay.test.ts` — stub `RelayTarget` + registry, 19 cases: `deliverRelayReport` no-op without `reply_to`, waits while a child is active, delivers exactly one append + one send with the header then clears, `ABORTED` prefix on `turnError` (and the account-error variant: "fix the model config" instead of the re-send hint), ignores sub-sessions; `relay_send` creates + stamps `reply_to` + channel-prefixes the model text, rejects a nonexistent workspace, returns the permanent error without a registry (CLI / TUI); `relay_interrupt` busy → enqueue then abort + re-stamp, idle → no abort and never creates; `relay_list` roots only with title fallback, and defaults to the caller's own workspace; in the caller's own workspace `relay_send` / `relay_interrupt` / `relay_stop` refuse self and ancestors (no send / abort / stop, `reply_to` untouched) while another session there — or the same id in another workspace — still goes through. `deliverRelayInterim`: no-op without `reply_to`; delivers the interim header and keeps `reply_to`, the later final report still fires once. End-to-end through `drainQueue` / `runSession` (relay interim → final, no kick → no interim, local message → no interim, opening-turn and deferred interims incl. the local-opening-message and quiet-subtree negatives) lives in `test/continue-task.test.ts`.
@@ -114,7 +114,7 @@ interface AgentSession {
114
114
  contextConfig: { maxTokens, compressAt }
115
115
  isCompacting: boolean
116
116
  foldAfterCompact: string | null // queue text a Stop folded while a compact was in flight; clearCompacting lands it on the post-compact history
117
- interruptRequested: boolean // soft-interrupt flag — abort after the current tool_result
117
+ interruptRequested: boolean // soft-interrupt flag — abort at the next batch boundary
118
118
  selfKick: boolean // armed by the built-in continue_task tool; drainQueue turns it into one synthetic resume turn (one-turn lifetime)
119
119
  resumedAfterInterrupt: boolean // this turn was started by drainQueue after an interrupt — the only state in which continue_task arms selfKick
120
120
  workingDir: string | null // resolved working directory (null = project root)
@@ -182,6 +182,8 @@ The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the *
182
182
 
183
183
  **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.
184
184
 
185
+ **AgentCore A2A mode** (`a2a/agentcore.ts`): the ledger is `<HALO_WORKSPACE>/.halo/runs.db` on the EFS mount instead of `~/.halo/global/runs.db` (a microVM's home dies with it), opened only once the workspace lease is held; the sweep runs on lease acquisition rather than at boot, and the first `POST /` waits for its nudges to start (`nudgesSettled`) before it is served — see [agentcore.md](agentcore.md). That file joins the workspace-relative hidden list (`WORKSPACE_HIDDEN_FILES`).
186
+
185
187
  `runs.db`/`-wal`/`-shm` join the built-in sandbox hidden-files list (`DEFAULT_HIDDEN_FILES`, always included; `general.sandbox.hidden_files` only adds to it) 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.
186
188
 
187
189
  ### Message queue and drain
@@ -190,7 +192,7 @@ The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the *
190
192
 
191
193
  A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is set for agent→agent entries (drives the `(from: session X)` prefix and the sibling-status suffix) and absent for user messages; `images` is the user multimodal payload (agent entries have none).
192
194
 
193
- **runSession loop** (`runSession(sessionId, message)`, `message: string | ContentBlock[]`): runs the opening turn when there is one, then drains. An empty **string** message means "the work is already in `messageQueue`" (the `querySession` idle path) — it skips the opening turn and goes straight to drain. The per-turn reset (fresh `toolCallLog` / `warnedToolHashes`, `interruptRequested = false`) runs before the opening turn; `drainQueue` repeats the same reset before each merged batch so a stale interrupt flag never leaks across turns. Server-only: right before `session.promise = runFn()` it inserts the session id into the run ledger, and in the `finally` (right after `session.promise = null`, before emitting `complete`) it deletes it — see [Run ledger](#run-ledger--restart-nudge-for-interrupted-roots-halo-globalrunsdb) above.
195
+ **runSession loop** (`runSession(sessionId, message)`, `message: string | ContentBlock[]`): runs the opening turn when there is one, then drains. An empty **string** message means "the work is already in `messageQueue`" (the `querySession` idle path) — it skips the opening turn and goes straight to drain. The per-turn reset (fresh `toolCallLog` / `warnedToolHashes`, `interruptRequested = false`) runs before the opening turn; `drainQueue` repeats the same reset before each merged batch so a stale interrupt flag never leaks across turns. Server-only: right before `session.promise = runFn()` it inserts the session id into the run ledger, and in the `finally` (right after `session.promise = null`, before emitting `complete`) it deletes it — see [Run ledger](#run-ledger--restart-nudge-for-interrupted-roots-haloglobalrunsdb) above.
194
196
 
195
197
  **Observability.** `runAgentTurn` is wrapped by `beginTurn` / `endTurn` from `observability/genai-spans.ts` — one `invoke_agent` span per turn, with `chat` / `execute_tool` child spans created from the `onAgentEvent` hook inside the event loop; see [design/observability.md](observability.md).
196
198
 
@@ -208,9 +210,9 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
208
210
  - **`batchBoundary` complete**: when a merged round finishes **and the queue still has a next round**, `drainQueue` emits `{ type: 'complete', batchBoundary: true }`. This is a per-round flush signal for **block-oriented channels** (wechat / telegram / slack / feishu / wecom), which buffer streamed text and only ship a message on `complete` — without it, N drain rounds buffer into one blob that lands only at the terminal `complete` ("8 reports in one lump"). **Stream-terminating consumers (web-channel SSE, ACP) must ignore `batchBoundary` and keep the stream open** — the root is still running and more output follows; only the terminal (unmarked) `complete` closes the stream. See [web.md](web.md) and the per-channel coalescing notes (e.g. [wechat.md](wechat.md)). The WS `chat:complete` frame carries the field too — admin closes its bubble either way but rings the finish bell only on the terminal one.
209
211
 
210
212
  **Three-tier interrupt model** — two **soft** paths and one **hard** path, distinguished by *when* the abort fires, never by whether the message is kept (all three preserve the queue):
211
- 1. **`query_session` while busy — soft interrupt.** `querySession(..., interrupt=false)` enqueues **and** sets `interruptRequested` (same as a user message). This is what makes a sub-agent fold two queued questions into **one merged answer** instead of replying one-by-one: the in-flight turn unwinds after its current tool, then `drainQueue`'s `splice(0)` folds every message that landed alongside it into a single round — matching how root handles two user messages. An **idle** target has no turn to interrupt, so it just runs the message directly.
212
- 2. **User message while busy — soft interrupt.** `sendUserMessage` (busy branch) pushes the message and sets `interruptRequested`. The graceful point is **after a `tool_result`**: `runAgentTurn` aborts only once the current tool finishes, so a mid-flight `shell_exec` is **not** killed — it runs to completion, then the turn unwinds and `drainQueue` folds the message into the next round.
213
- 3. **`interrupt_session` — hard interrupt.** `querySession(..., interrupt=true)` enqueues, then `interruptSession` aborts **immediately** (not at the next `tool_result`), so a mid-flight command **is** SIGTERM'd. For this to actually kill a *compound* command (`sleep 60 && …`), the `full`-access shell path runs the command as a **process-group leader** and signals the whole group — see [Process-group kill](#process-group-kill-on-abort) below. The enqueued message drains on the same wake-up.
213
+ 1. **`query_session` while busy — soft interrupt.** `querySession(..., interrupt=false)` enqueues **and** sets `interruptRequested` (same as a user message). This is what makes a sub-agent fold two queued questions into **one merged answer** instead of replying one-by-one: the in-flight turn unwinds after its current tool batch, then `drainQueue`'s `splice(0)` folds every message that landed alongside it into a single round — matching how root handles two user messages. An **idle** target has no turn to interrupt, so it just runs the message directly.
214
+ 2. **User message while busy — soft interrupt.** `sendUserMessage` (busy branch) pushes the message and sets `interruptRequested`. The graceful point is the **batch boundary**: `runAgentTurn`'s `beforeCallModel` hook aborts right before the *next* model call, so the current `tool_use` batch always runs to completion — a mid-flight `shell_exec` is **not** killed and no call in a parallel batch is skipped — then the turn unwinds and `drainQueue` folds the message into the next round. Never before the turn's **first** batch: a message that lands during the first model call lets the turn proceed (it merges at the next boundary, or drains after the turn ends), so a turn never ends on a bare user message. Nothing is pending at a boundary, so the soft path emits no UI marker and leaves nothing for repair.
215
+ 3. **`interrupt_session` — hard interrupt.** `querySession(..., interrupt=true)` enqueues, then `interruptSession` aborts **immediately** (not at the next batch boundary), so a mid-flight command **is** SIGTERM'd. For this to actually kill a *compound* command (`sleep 60 && …`), the `full`-access shell path runs the command as a **process-group leader** and signals the whole group — see [Process-group kill](#process-group-kill-on-abort) below. The enqueued message drains on the same wake-up.
214
216
 
215
217
  **`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`:
216
218
  - **`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.
@@ -219,7 +221,7 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
219
221
  - **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`.
220
222
  - **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.
221
223
  - **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.
222
- - **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.
224
+ - **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 at that same boundary once the compact returns, instead of running to its natural end with the message still queued.
223
225
 
224
226
  **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.
225
227
 
@@ -251,6 +253,8 @@ Two event-field notes (`agents/agent-events.ts`):
251
253
 
252
254
  The five session tools that take an existing `session_id` — `query_session`, `interrupt_session`, `stop_session`, `archive_session`, `get_session_output` — are scoped to the **caller's own session tree**. The check is the same root-prefix primitive used everywhere else: `targetId.split('>')[0] === callerId.split('>')[0]` (mirrors `findRootSessionId`). A cross-tree id is refused as `{"code": 1, "error": "session <id> not found"}` — not-found phrasing avoids leaking a foreign session's existence.
253
255
 
256
+ Within the tree, `stop_session` / `archive_session` (and `relay_stop` aimed at the caller's own workspace) also refuse the caller's **own session or an ancestor** (`callerId === targetId || callerId.startsWith(targetId + '>')` — `isSelfOrAncestor` in `session-tools.ts`). Both cascades await every running turn in the target's subtree, and that subtree contains the turn running the tool call, so the await never settled and the turn hung until restart. `interrupt_session` on self or an ancestor does not hang — `interruptSession` is fire-and-forget — and stays allowed. (`relay_send` / `relay_interrupt` refuse self / an ancestor in the own workspace too, for a different reason: the relay report would land back in the caller's own tree — see [relay.md](relay.md).)
257
+
254
258
  Why it's needed: one `SessionManager` per workspace holds **every** user's/channel's session trees (root ids are prefixed `web_<acct>_…` / `tg_<userId>_…` / …). Session ids are hierarchical strings and the full id is enumerable — a child knows its own id (and thus its root, the left-most segment), and any agent with `file_read` can read `.halo/sessions/<agentId>/<sid>.json`, which persists each session's `id` + `parentSessionId`. Without the gate, a prompt-injected agent on one tree could enumerate a foreign root id and `archive` (irreversible cascade), `stop` (DoS), or `get_session_output` (read transcript) another user's tree. This is the only by-id session entrypoint; the user-facing paths (`/switch`, `/session info`/`/tree`, `visibleSessions`) already enforce the same prefix scoping.
255
259
 
256
260
  The gate lives at the **tool callback layer** (`agents/session-tools.ts`), not inside the SessionManager methods — those same methods are also reached by already-authorized user paths and by the internal auto-report (`tryReportToParent` → `querySession(parentId, …)`, always in-tree), so an in-method check would break legitimate callers. `start_session` / `query_agent` gate at the same callback layer (on agent_id + team), so all delegation authorization sits in one place. Covered by `test/session-agent-builder.test.ts` (cross-tree refused without touching the target; same-tree passes through).
@@ -310,7 +314,7 @@ A `toolUseId`-based algorithm that repairs message arrays damaged by abort / int
310
314
 
311
315
  **Why synthesize instead of strip** (Phase 2): stripping an orphaned `toolUse` made the model believe the call *never happened*, so after an Esc / `interrupt_session` / stop aborted an in-flight tool, the next turn dutifully re-issued the same call — an interrupted `sleep 30` re-ran in full, doubling time and tokens. The synthesized result keeps the pair protocol-valid *and* tells the model the call was cut short, with wording that steers it away from an automatic retry. It lives in the shared repair path (not at each abort call site) because every interrupt flavor, crash recovery on reload, and the API-400 repair-retry all funnel through here. Idempotent: a synthesized result pairs its `toolUse`, so a later pass sees a match and does nothing.
312
316
 
313
- **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.
317
+ **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 hard-interrupt exits — the loop's own cancel checks (the abort lands while a tool runs, or between tools) and the consumer breaking its `for await` (finishes the generator at the `yield` via `.return()`) — still land the results that finished. A soft interrupt never cuts a batch (it waits for the batch boundary, see the [three-tier model](#message-queue-and-drain)). 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 with the do-not-retry marker. Calls the batch never started are answered in the same `finally` with an `is_error` "was not run — the turn was interrupted before this call started. Safe to re-issue if still needed." result (the same land-before-exit pattern as the `max_tokens` not-run results), so repair's marker only ever meets a call that was genuinely cut mid-run.
314
318
 
315
319
  **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.
316
320
 
@@ -318,12 +322,11 @@ A `toolUseId`-based algorithm that repairs message arrays damaged by abort / int
318
322
 
319
323
  ### UI-side interrupt marker (`markPendingToolCallsInterrupted`)
320
324
 
321
- The synthesized `[interrupted]` result above is **model-facing only** (`agent.messages`). The session UI log has a parallel problem: on a hard abort, `runAgentTurn`'s consumer loop breaks on `signal.aborted` before it processes the in-flight tool's real `tool_result` event, so pending tool-call blocks stayed "running" forever. `SessionManager.markPendingToolCallsInterrupted(sessionId)` scans the session's cached UI state for tool calls with no output yet and emits a synthetic `[interrupted by user]` `tool_result` for each through the normal `emitEvent` pipeline — ui-log-builder persistence, admin WS push, and TUI rendering pick it up unchanged. It never touches `agent.messages`. Called at the four abort sites:
325
+ The synthesized `[interrupted]` result above is **model-facing only** (`agent.messages`). The session UI log has a parallel problem: on a hard abort, `runAgentTurn`'s consumer loop breaks on `signal.aborted` before it processes the in-flight tool's real `tool_result` event, so pending tool-call blocks stayed "running" forever. `SessionManager.markPendingToolCallsInterrupted(sessionId)` scans the session's cached UI state for tool calls with no output yet and emits a synthetic `tool_result` for each through the normal `emitEvent` pipeline — ui-log-builder persistence, admin WS push, and TUI rendering pick it up unchanged. The batch runs serially and `setToolResult` fills the first pending entry, so the **first** pending call is the one that was running → `[interrupted by user]` (model side: repair's do-not-retry marker); the rest never started → `[not run — interrupted]` (model side: agent-loop's "not run" result). It never touches `agent.messages`. Called at the three hard abort sites (the soft interrupt waits for the batch boundary, where nothing is pending):
322
326
 
323
- 1. **Graceful-interrupt-after-tool_result** in `runAgentTurn` — in a parallel-tool turn, tool_calls after the current one were already announced (agent-loop yields all upfront) but will never execute; close their blocks.
324
- 2. **`interruptSession`** (hard interrupt).
325
- 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).
326
- 4. **`stopUserSession`** (the user Stop button).
327
+ 1. **`interruptSession`** (hard interrupt).
328
+ 2. **`stopSession`**'s cascade — per descendant, before awaiting the aborted promise (runSession's finally emits `complete`, which flushes and clears the pending buffers this scans).
329
+ 3. **`stopUserSession`** (the user Stop button).
327
330
 
328
331
  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.
329
332
 
@@ -28,6 +28,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
28
28
  │ ├── evo.db # Cross-workspace evolution queue (evolution_runs + evolution_applies)
29
29
  │ ├── cron.db # Cross-workspace cron jobs + run history (cron_jobs + cron_runs)
30
30
  │ ├── runs.db # Run ledger: (workspace, session_id) rows for server-driven runs in flight; steady state empty (running_sessions)
31
+ │ ├── a2a.db # A2A: inbound tasks, push configs + outbox, outbound dispatches (see design/a2a.md)
31
32
  │ ├── extensions/<id>/ # Installed Canvas preview extensions (halo-extension.json + bundle)
32
33
  │ ├── logs/ # Runtime logs (server.log)
33
34
  │ │ ├── evo/ # Per-evo-run wrapper logs
@@ -37,6 +38,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
37
38
  ├── secrets/ # Sensitive files (not mounted into sandbox)
38
39
  │ ├── settings.yaml # Global settings (API keys, credentials)
39
40
  │ ├── config.yaml # System config (admin password, server settings)
41
+ │ ├── a2a-remotes.yaml # A2A remote list (server-wide, full-only; see design/a2a.md)
40
42
  │ └── channels/
41
43
  │ └── channels.db # Channel accounts DB
42
44
 
@@ -66,6 +68,9 @@ Defines the persisted-data format for every Halo surface. Format changes must re
66
68
  ├── assets/<channel>/inbound/<accountId>/<date>/ # Inbound media per channel (image/voice/video/file)
67
69
  ├── runtime.lock # Workspace runtime ownership marker (pid) — see below
68
70
  ├── halo.db # Per-workspace sqlite (sessions metadata, disabled-items)
71
+ ├── a2a.db # AgentCore mode only: A2A tasks + push outbox (instead of ~/.halo/global/a2a.db) — design/agentcore.md
72
+ ├── runs.db # AgentCore mode only: the run ledger (instead of ~/.halo/global/runs.db)
73
+ ├── agentcore.lease # AgentCore mode only: single-writer heartbeat lease file
69
74
  └── docs/ # Project docs (requirements/design/dev/test/plans)
70
75
  ```
71
76
 
@@ -402,8 +407,10 @@ A value of the form `<<ENV_NAME>>` is replaced at read time with `process.env.EN
402
407
  | `general.limits.tool_result_ui_chars` | 65536 | Per-tool-result cap on the content **stored for UI display** (admin/web chat panel). Far larger than the LLM cap so a normal command's full output stays visible, but bounded so a multi-MB `cat` can't bloat the session file / WS payload / browser render; excess truncated with a marker pointing at `file_read` |
403
408
  | `general.limits.terminal_scrollback_bytes` | 50000 | Off-screen scrollback bytes retained per detached persistent terminal |
404
409
  | `general.sandbox.hidden_dirs` | `''` | Extra dirs hidden from workspace/readonly sessions — bwrap tmpfs overlay (Linux) / Seatbelt deny (macOS). **Appended** to the built-in `DEFAULT_HIDDEN_DIRS` (`tools/sandbox.ts`: `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs`), which are always included. Full sessions, the admin file explorer and the terminal are not affected |
405
- | `general.sandbox.hidden_files` | `''` | Extra files hidden from workspace/readonly sessions — bwrap bind of the empty `~/.halo/.sandbox-empty`, reads as empty (Linux) / Seatbelt deny (macOS). **Appended** to the built-in `DEFAULT_HIDDEN_FILES` (`~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + `-wal` / `-shm`), which are always included. Full sessions, the admin file explorer and the terminal are not affected |
410
+ | `general.sandbox.hidden_files` | `''` | Extra files hidden from workspace/readonly sessions — bwrap bind of the empty `~/.halo/.sandbox-empty`, reads as empty (Linux) / Seatbelt deny (macOS). **Appended** to the built-in `DEFAULT_HIDDEN_FILES` (`~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs,a2a}.db` + `-wal` / `-shm`), which are always included. Full sessions, the admin file explorer and the terminal are not affected |
406
411
  | `general.logging.level` | warn | `debug` / `info` / `warn` / `error`. Restart required |
412
+ | `general.a2a.public_url` | `''` | Global-only. Origin peers use to reach this server for A2A (no trailing slash): the agent card's interface URL and the default push-webhook base for `a2a_send`. Empty = derived from the request host (no outbound dispatch without it or a remote's `push_base`). Env `HALO_A2A_PUBLIC_URL` overrides. See [a2a.md](a2a.md) |
413
+ | `general.a2a.url_allowlist` | `100.64.0.0/10,*.ts.net` | Global-only. CIDRs / host patterns A2A egress may reach besides public https (push webhooks, remote cards and RPC, inbound image URLs). Private / loopback only when listed; plain http only to listed hosts / ranges; link-local always refused. See [a2a.md](a2a.md#push-delivery-and-url-policy) |
407
414
  | `general.observability.endpoint` | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`); empty = off. Restart required. See [observability.md](observability.md) |
408
415
  | `general.observability.service_name` | `halo` | OTel resource `service.name`. Restart required |
409
416
  | `general.observability.headers` | `''` | **Secret.** Extra OTLP request headers, comma-separated `k=v`. Restart required |
@@ -634,7 +641,7 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
634
641
  ### Schema change rules
635
642
 
636
643
  - `schema.sql` / each db's `CREATE_SQL` always describes the FULL current shape, so a fresh db is complete after the CREATEs alone
637
- - Every change to an already-existing db gets a numbered slot in that file's ordered migration list — `HALO_MIGRATIONS` (`db/index.ts`), `CRON_MIGRATIONS` (`db/cron-db.ts`); `channel-db.ts` / `evo-db.ts` / `runs-db.ts` currently pass an empty list. `runMigrations(sqlite, list)` (`db/migrate.ts`) runs `list[user_version..]` in order, each in its own transaction, stamping `PRAGMA user_version = i + 1` after each
644
+ - Every change to an already-existing db gets a numbered slot in that file's ordered migration list — `HALO_MIGRATIONS` (`db/index.ts`), `CRON_MIGRATIONS` (`db/cron-db.ts`), `A2A_MIGRATIONS` (`db/a2a-db.ts`); `channel-db.ts` / `evo-db.ts` / `runs-db.ts` currently pass an empty list. `runMigrations(sqlite, list)` (`db/migrate.ts`) runs `list[user_version..]` in order, each in its own transaction, stamping `PRAGMA user_version = i + 1` after each
638
645
  - A fresh db is also at `user_version 0` and runs the whole list, so every slot must be a no-op against the current shape (`addColumnIfMissing`, `CREATE … IF NOT EXISTS`)
639
646
  - Append only — never reorder or edit a shipped slot. A db opened by a newer halo (user_version > list length) logs a warning and continues
640
647
  - Do not drop or rename existing columns
@@ -10,13 +10,12 @@ Browser-based access to Halo via token-authenticated HTTP API. Supports SSE stre
10
10
  Halo server (9527) ──┤── channels/telegram/ ├── SessionManager
11
11
  └── channels/web/ ─┘ (per workspace, via Registry)
12
12
  ↕ HTTP + SSE
13
- web-demo (9528) ── browser
14
- or any HTTP client
13
+ any HTTP client (custom frontend, halo-city, ACP adapter)
15
14
  ```
16
15
 
17
16
  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.
18
17
 
19
- Web channel is a public HTTP API on Halo. `packages/web-demo` is a standalone demo frontend that proxies requests through its own auth layer.
18
+ Web channel is a public HTTP API on Halo.
20
19
 
21
20
  ## Data model
22
21
 
@@ -63,7 +62,7 @@ File: `packages/server/src/routes/web.ts`
63
62
 
64
63
  All public endpoints require `x-token` header (or `?token=` query param) with a valid account token. No cookie auth needed.
65
64
 
66
- > These paths are listed in `PUBLIC_PATHS` (`middleware/auth.ts`) so they bypass the admin cookie gate: `/api/web/chat`, `/api/web/stop`, `/api/web/history`, `/api/web/subscribe`, `/api/web/file`, and `/api/show/state` (the [halo-city](../../../halo-city/) snapshot — see [dev/api.md](../dev/api.md#show-world-snapshot)). The server CORS allowlist includes the `x-token` header, so browser-based custom frontends (web-demo, halo-city) can call these cross-origin.
65
+ > These paths are listed in `PUBLIC_PATHS` (`middleware/auth.ts`) so they bypass the admin cookie gate: `/api/web/chat`, `/api/web/sessions` (POST mint + GET list), `/api/web/stop`, `/api/web/history`, `/api/web/subscribe`, `/api/web/file`, and `/api/show/state` (the [halo-city](../../../halo-city/) snapshot — see [dev/api.md](../dev/api.md#show-world-snapshot)). The server CORS allowlist includes the `x-token` header, so browser-based custom frontends (e.g. halo-city) can call these cross-origin.
67
66
 
68
67
  ### POST `/api/web/chat`
69
68
 
@@ -82,8 +81,9 @@ Send a message and receive streaming response via SSE.
82
81
  // Response: text/event-stream
83
82
  data: {"type":"session","sessionId":"web_abc123_m1xyz"}
84
83
  data: {"type":"thinking","text":"..."}
85
- data: {"type":"tool_call","toolName":"read_file","toolInput":{...}}
86
- data: {"type":"tool_result","toolName":"read_file","result":"..."}
84
+ data: {"type":"tool_call","toolName":"file_read","toolUseId":"toolu_01…","toolInput":{...}}
85
+ data: {"type":"tool_result","toolName":"file_read","toolUseId":"toolu_01…","result":"..."} // result capped at 500 chars
86
+ : keepalive // SSE comment every 15 s, see below
87
87
  data: {"type":"stream","text":"Hello! "}
88
88
  data: {"type":"stream","text":"How can I help?"}
89
89
  data: {"type":"switch","sessionId":"..."} // after /session switch or /session new command
@@ -91,6 +91,12 @@ data: {"type":"complete"}
91
91
  data: {"type":"error","error":"..."}
92
92
  ```
93
93
 
94
+ > **`toolUseId`**: both tool frames carry the provider's tool_use id, so a client can pair a result with its call even when calls interleave (the [ACP adapter](../dev/acp-adapter.md) uses it as the ACP `toolCallId`, and it matches the `toolUseId` in `/web/history`'s assistant `contentBlocks`). Additive — clients that pair by order can ignore it. It can be an empty string (some OpenAI-compatible streams carry no id); treat empty as missing.
95
+
96
+ #### SSE keepalive
97
+
98
+ `/web/chat` and `/web/subscribe` write an SSE comment line `: keepalive` every 15 s (`SSE_KEEPALIVE_MS` / `streamWithKeepalive` in `routes/web.ts`; the timer is cleared when the stream ends). A long tool call can otherwise leave the stream silent long enough for an idle reverse proxy / CDN to cut it. Comment lines aren't events — `EventSource` and any spec-following parser drop them; a hand-rolled parser must skip lines that don't start with `data:`.
99
+
94
100
  > **Batch-boundary `complete` (must be absorbed, never closes the stream)**: when a root session drains a queue of multiple messages, the server runs N merged turns and emits an internal `complete` with `batchBoundary: true` between rounds (see [session.md](session.md#message-queue-and-drain)). The SSE generators in `channels/web/handler.ts` (`listenSession().events()` plus the `createMediaBuffer()` event processor) flush the just-finished round's text on a `batchBoundary` complete but **do not** send a `complete` SSE frame and **do not** set `done` — the response stays open for the next round; only the **terminal** (unmarked) `complete` closes the HTTP stream. Without this guard a producer→sub-agent fan-out would truncate the web client after the first round. The `batchBoundary` flag is therefore an internal server-side event marker only — it is never serialized into the SSE payload a client sees, so a custom frontend just consumes one ordinary `complete` at the end. (ACP rides on this channel, so it inherits the same safe behavior.)
95
101
 
96
102
  > **Listener lifetime**: `handleMessage` registers its session listener (`listenSession`) *before* the media save and `sendUserMessage` — it must be listening when the turn starts — and wraps everything from there to the end of `events()` in a `try/finally` that closes it. So a throw in between (media save failed, `sendUserMessage` threw), a `queued` result, or the end of the stream all drop the listener; before, only `events()`'s own finally and the `queued` branch did, and an early throw leaked it for the process lifetime. The unsubscribe is idempotent, so closing twice on the normal path is harmless. The route passes the request's abort signal (`c.req.raw.signal`) through to `events(signal)` — for `/web/chat` and a skill command's follow-on stream, as `/web/subscribe` already did — so a client that disconnects mid-turn ends the stream and drops the listener right away instead of at the turn's terminal `complete`. The turn itself keeps running; disconnecting is not `/web/stop`.
@@ -100,10 +106,10 @@ data: {"type":"error","error":"..."}
100
106
  By default each token is bound 1:1 to the workspace its admin row configured, and `/web/chat` operates on the account's "active" session (most-recently-used or one set by `/session new` / `/session switch`). External integrations — currently the [ACP adapter](../dev/acp-adapter.md) — need finer control:
101
107
 
102
108
  - `workspace` (string, optional): server-side absolute path. Overrides `account.workspacePath` for this request only. **Gated on `accessLevel === 'full'`** — readonly / workspace tokens cannot escape their account-bound workspace; the gate returns an SSE `error` event.
103
- - `sessionId` (string, optional): explicit halo session id. Bypasses the account's active-session pointer entirely. **Gated by ownership**: `full` tokens may address any id; readonly / workspace tokens only ids their own account minted (`web_<accountId>_*`) — anything else is refused with HTTP 403 `{error: "session not owned by this token"}` at the route layer (before the SSE stream opens), on `/web/chat`, `/web/stop`, `/web/history` and `/web/subscribe` alike. If the session doesn't yet exist, the server creates it with the supplied id (so callers can pre-mint stable ids and address them across reconnects).
109
+ - `sessionId` (string, optional): explicit halo session id. Bypasses the account's active-session pointer entirely. **Gated by ownership**: `full` tokens may address any id; readonly / workspace tokens only ids their own account minted (`web_<accountId>_*`), plus the goal session (`goal_<ts>`) bound to one of those — goal mode routes that session's chat there and the `session` frame names it, so re-attach / history / stop must reach it. Anything else is refused with HTTP 403 `{error: "session not owned by this token"}` at the route layer (before the SSE stream opens), on `/web/chat`, `/web/stop`, `/web/history` and `/web/subscribe` alike. If the session doesn't yet exist, the server creates it with the supplied id (so callers can pre-mint stable ids and address them across reconnects).
104
110
  - `agentId` (string, optional): only consulted when the request is creating a new session (no row yet for `sessionId`). Picks the agent profile to bootstrap with. Defaults to `default`.
105
111
 
106
- Browser web-demo doesn't use any of these — it relies on per-token defaults. Three accepted transports per request, lowest-priority first:
112
+ Simple clients needn't use any of these; per-token defaults apply. Three accepted transports per request, lowest-priority first:
107
113
 
108
114
  1. Query string: `?workspace=…&sessionId=…&agentId=…`
109
115
  2. Headers: `x-workspace`, `x-session-id`, `x-agent-id`
@@ -121,6 +127,25 @@ Minting never touches the account's active-session pointer — a side session cr
121
127
 
122
128
  **Namespace sharing**: sessions minted here live under the same `web_<accountId>_` prefix as browser sessions. When the token has no active-session pointer, `/web/chat` without `sessionId` falls back to the latest root session under that prefix — which may be an API-minted one. Use a dedicated token for ACP if the same token also drives a browser client.
123
129
 
130
+ ### GET `/api/web/sessions`
131
+
132
+ One page of the token's own root sessions — `web_<accountId>_*`, `parent_id IS NULL`, not archived — newest `updatedAt` first, 50 per page (`listSessions` in `channels/web/handler.ts`, over `SessionManager.listSessions({ rootOnly, prefix, cursor })`). Backs ACP `session/list`.
133
+
134
+ ```json
135
+ // GET /api/web/sessions?cursor=<nextCursor> (cursor optional)
136
+ {
137
+ "workspace": "/abs/resolved/path",
138
+ "sessions": [ { "sessionId": "web_abc123_m1xyz_q2", "title": "…", "updatedAt": 1791384914714 } ],
139
+ "nextCursor": 1791384900000 // null on the last page
140
+ }
141
+ ```
142
+
143
+ - `title` is the session title, falling back to its description, else `null`.
144
+ - `cursor` = the previous page's `nextCursor` (an `updatedAt` epoch-ms); the next page is rows updated strictly before it. Digits only — anything else (`0x10`, `1e3`, `-1`) → 400 `{error: "Invalid cursor"}`; an empty `cursor=` is the same as none (first page). Rows sharing the boundary millisecond can be skipped (shared list-query behaviour).
145
+ - A `workspace` override naming a directory with no `.halo/` → an empty page; the list never scaffolds a workspace (same guard as the admin `GET /api/sessions/logs`).
146
+ - **Prefix-scoped for every access level, `full` included** — the list is "this token's conversations", not the workspace's. A full token can still address any id it knows on the other routes.
147
+ - `workspace` override via query / `x-workspace` header, full tokens only (403 otherwise, like the mint). Bad token → 401.
148
+
124
149
  ### POST `/api/web/stop`
125
150
 
126
151
  Stop the currently running task. Accepts optional `workspace` / `sessionId` overrides as documented above.
@@ -134,6 +159,8 @@ Stop the currently running task. Accepts optional `workspace` / `sessionId` over
134
159
 
135
160
  Get a session's message history. Without overrides, returns the account's active session; with `sessionId` (and optionally `workspace`), returns whichever session you address.
136
161
 
162
+ Optional `since=<epoch ms>`: only root-log rows (no `taskId` — sub-agent rows dropped) whose `timestamp >= since`, filtered **before** serialization. For a caller that only needs the latest turn — the [ACP adapter](../dev/acp-adapter.md) settling a reply after a reconnect / queued drain passes its turn's start time — instead of the whole log, which on a long session runs to many MB of JSON built on the server's main thread. Digits only; anything else → 400 `{error: "Invalid since"}`; empty = absent. Rows are stamped with the server's clock.
163
+
137
164
  ```json
138
165
  // Response
139
166
  {
@@ -145,7 +172,7 @@ Get a session's message history. Without overrides, returns the account's active
145
172
 
146
173
  ### GET `/api/web/subscribe`
147
174
 
148
- Reconnect to a running session's event stream (same SSE format as `/chat`). If session is not running, returns a single `complete` event immediately. Accepts the same `workspace` / `sessionId` overrides.
175
+ Reconnect to a running session's event stream (same SSE format as `/chat`, keepalive included). Opens with a `session` frame; then, if the session is idle, sends a single `complete` and closes immediately — a manual `/compact` with no turn in flight counts as idle unless messages are queued (only then does `endCompact` drain them into a turn that ends in `complete`) — otherwise it streams until the run's terminal `complete` (batch-boundary completes absorbed as on `/chat`). The listener is registered *before* the idle check, so a turn that ends in between still delivers its `complete`. (Before 2026-10 the idle case waited for the next turn's `complete` instead of returning.) Accepts the same `workspace` / `sessionId` overrides.
149
176
 
150
177
  ## Halo API — Admin endpoints
151
178
 
@@ -160,56 +187,6 @@ Protected by cookie auth (admin panel). File: `packages/server/src/routes/web.ts
160
187
 
161
188
  ---
162
189
 
163
- ## packages/web-demo — Standalone Demo Frontend
164
-
165
- An independent Express app that provides a browser UI for the web channel. It holds the Halo token server-side and exposes its own password-based auth.
166
-
167
- ### Deployment
168
-
169
- ```bash
170
- cd packages/web-demo
171
- node server.js
172
- ```
173
-
174
- ### Environment variables
175
-
176
- | Variable | Required | Description |
177
- |---|---|---|
178
- | `HALO_API` | Yes | Halo server URL (e.g. `http://localhost:9527`) |
179
- | `HALO_TOKEN` | Yes | Token from Halo admin panel (Web settings) |
180
- | `HALO_WEB_DEMO_PASSWORD` | No | Password for web-demo login. Empty = open access |
181
- | `PORT` | No | Listen port (default: `9528`) |
182
-
183
- ### Security model
184
-
185
- - **Proxy mode (default)**: `HALO_TOKEN` and `HALO_API` are server-side only, never exposed to the browser; frontend uses same-origin relative paths (`/chat`, `/history`, etc.); web-demo server proxies all requests to Halo, injecting the token in `x-token` header. Session auth: HMAC token in `x-session` header + localStorage. All proxy routes including `GET /file` are gated by the auth middleware.
186
- - **Session TTL: 7 days**, enforced without server-side storage — the signed payload *is* the issue timestamp, so `isValidSession` re-parses it and rejects anything older than `SESSION_TTL_MS`. A leaked token therefore stops working on its own; the frontend gets a 401 and falls back to the login view (`handleAuthFail → logout`). Previously the signature alone was checked, making every issued token valid forever.
187
- - **Direct-connect mode (opt-in)**: the gear panel takes a halo server URL + web-channel token; the browser then calls `<server>/api/web/*` directly with `x-token`, bypassing the proxy (no password step). The token is stored in that browser's localStorage by explicit user choice — the UI says so next to the field. One slot; clearing it returns to proxy mode. Works because the server CORS reflects any origin and allowlists `x-token`.
188
-
189
- ### Proxy routes
190
-
191
- | Frontend path | Upstream Halo path |
192
- |---|---|
193
- | `POST /chat` | `POST /api/web/chat` |
194
- | `POST /stop` | `POST /api/web/stop` |
195
- | `GET /history` | `GET /api/web/history` |
196
- | `GET /subscribe` | `GET /api/web/subscribe` |
197
- | `GET /file` | `GET /api/web/file` |
198
-
199
- ### Features
200
-
201
- - Markdown rendering for assistant text (marked@12, gfm + breaks) with typewriter streaming; user text stays escaped plain text
202
- - SSE streaming with collapsible thinking / tool-call step blocks
203
- - Auto-reconnect: if session is running on page load, subscribes to live stream; history renders rebuild-from-scratch (never append)
204
- - Message queue: can send while agent is running, messages queue and send sequentially
205
- - Slash commands (`/help`, `/session`, `/agent`, `/skill`, `/workspace`), session/agent switcher menus
206
- - Image upload + camera capture (camera button is mobile-only — the `capture` attribute is a no-op on desktop, where it would duplicate the file picker)
207
- - Voice recording (webm audio saved to workspace, path given to agent)
208
- - Stop/interrupt button
209
- - i18n: auto-detects browser language (`navigator.language`), toggle button in header, persists to `localStorage`
210
- - Mobile-first layout: 100dvh-safe, safe-area insets, ≥44px touch targets, bottom-sheet menus/settings on small widths, visual language shared with `packages/agentcore-demo`
211
- - Page load needs internet for two CDN deps (tailwindcss, marked) — no longer fully self-contained
212
-
213
190
  ## Integration guide (custom frontend)
214
191
 
215
192
  To build your own frontend against Halo's web channel: