@turmind/halo 1.5.9 → 1.5.11

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 (55) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/{597-399e2e1de26aa747.js → 941-4719e7343b54b9bd.js} +1 -1
  3. package/admin-out/_next/static/chunks/989-86b0f677f69ccf3b.js +1 -0
  4. package/admin-out/_next/static/chunks/app/page-4792aa1c79f7e7f1.js +1 -0
  5. package/admin-out/_next/static/css/97f3ac570664d044.css +3 -0
  6. package/admin-out/index.html +1 -1
  7. package/admin-out/index.txt +5 -5
  8. package/bundled-docs/design/agent.md +7 -5
  9. package/bundled-docs/design/canvas-extensions.md +14 -2
  10. package/bundled-docs/design/express-self.md +4 -4
  11. package/bundled-docs/design/halo-city.md +6 -0
  12. package/bundled-docs/design/prompt-system.md +1 -1
  13. package/bundled-docs/design/relay.md +6 -6
  14. package/bundled-docs/design/session.md +12 -11
  15. package/bundled-docs/design/web.md +33 -5
  16. package/bundled-docs/dev/acp-adapter.md +59 -175
  17. package/bundled-docs/dev/api.md +5 -2
  18. package/bundled-docs/dev/deploy.md +1 -1
  19. package/bundled-docs/dev/desktop-packaging.md +14 -0
  20. package/bundled-docs/dev/env.md +2 -2
  21. package/bundled-docs/dev/tools.md +8 -8
  22. package/bundled-docs/guide/README.md +1 -1
  23. package/bundled-docs/guide/channels/README.md +3 -3
  24. package/bundled-docs/guide/channels/acp.md +67 -42
  25. package/bundled-docs/guide/channels/feishu.md +1 -1
  26. package/bundled-docs/guide/channels/slack.md +1 -1
  27. package/bundled-docs/guide/channels/telegram.md +1 -1
  28. package/bundled-docs/guide/channels/web.md +10 -8
  29. package/bundled-docs/guide/channels/wechat.md +1 -1
  30. package/bundled-docs/guide/channels/wecom.md +1 -1
  31. package/bundled-docs/guide/chat.md +0 -1
  32. package/bundled-docs/guide/cli.md +1 -2
  33. package/bundled-docs/guide/getting-started.md +1 -1
  34. package/bundled-docs/guide/skills.md +1 -2
  35. package/bundled-docs/requirements/chat.md +28 -8
  36. package/bundled-docs/requirements/command.md +1 -2
  37. package/bundled-docs/requirements/editor.md +1 -0
  38. package/bundled-docs/requirements/explorer.md +1 -0
  39. package/bundled-docs/requirements/terminal.md +15 -0
  40. package/dist/index.js +810 -333
  41. package/package.json +1 -1
  42. package/templates/agents/__score__/AGENT.md +1 -1
  43. package/templates/agents/default/agent.yaml +0 -1
  44. package/templates/prompts/all/TOOL_SHELL.windows.md +2 -1
  45. package/templates/skills/skill/SKILL.md +1 -1
  46. package/templates/skills/workspace/stage.py +1 -1
  47. package/admin-out/_next/static/chunks/989-c404f6f43c400911.js +0 -1
  48. package/admin-out/_next/static/chunks/app/page-59bfce35bbb30b05.js +0 -1
  49. package/admin-out/_next/static/css/27b44c85485eabb9.css +0 -3
  50. package/templates/skills/acp/SKILL.md +0 -243
  51. package/templates/skills/acp/templates/SKILL.md.tmpl +0 -75
  52. package/templates/skills/acp/templates/ask.py +0 -177
  53. package/templates/skills/acp/templates/config.yaml.tmpl +0 -38
  54. /package/admin-out/_next/static/{RD5w3FmDZ6HtyQGGaUb8t → GO2W5JoiC46FIxLjtFAXx}/_buildManifest.js +0 -0
  55. /package/admin-out/_next/static/{RD5w3FmDZ6HtyQGGaUb8t → GO2W5JoiC46FIxLjtFAXx}/_ssgManifest.js +0 -0
@@ -431,6 +431,20 @@ Still good practice: build server + admin **before** `pnpm dist:arm64`.
431
431
  busy (recording) or unsaved, so it doesn't fire on an ordinary close or a
432
432
  workspace switch.
433
433
 
434
+ - **Chat screenshot button (`window.haloCapture.screenshot` → IPC
435
+ `halo:capture-screenshot`) — ⚠ not yet verified on a real Mac / Windows**
436
+ (only the main-side handler was exercised, under Electron on Linux/Xvfb).
437
+ mac / win only (`CAPTURE_SUPPORTED`; else `null`). Grabs the display the
438
+ requesting window is on (`screen.getDisplayMatching(win.getBounds())`) via
439
+ `desktopCapturer` `types: ['screen']` at `size × scaleFactor`, matched on
440
+ `display_id` (single source → used as is); returns base64 JPEG 92, `{ error:
441
+ 'permission' }` on an empty thumbnail (macOS Screen Recording not granted),
442
+ `null` after 3 mostly-black tries (`isMostlyBlack`, so a near-black desktop
443
+ also fails). The crop UI lives in the admin, not the shell. An older shell
444
+ without `screenshot` makes the admin fall back to `getDisplayMedia` (the
445
+ picker above). Worth checking on real hardware: the permission path, Retina
446
+ (`scaleFactor` > 1), multi-monitor.
447
+
434
448
  - **Cross-staging needs a full restage — auto-fast skips the native fixup.**
435
449
  After editing `stage-runtime.mjs`, or any time you cross-stage a target whose
436
450
  `node_modules` differs from what's on disk, run with `HALO_STAGE_FULL=1`. The
@@ -84,7 +84,7 @@ Three config file types, precedence **env vars > config.yaml / settings.yaml > c
84
84
 
85
85
  `init.ts` seeds these on first run with a per-category policy. Refresh trigger: `halo setup` always re-runs the seed, and the startup check re-runs it automatically when `~/.halo/global/.template-version` is behind the bundled `TEMPLATE_VERSION` — on server start and at the start of `halo cli` / `halo tui` (`init.ts` `refreshTemplatesIfOutdated`, called from `index.ts` and the CLI's `harness.ts` `initRuntime`; a never-seeded home is left to `halo setup`).
86
86
 
87
- - **Always overwritten** (platform-owned, refreshed when the template version moves): `~/.halo/global/{prompts,models,docs}/`, `INSTRUCTIONS.md`, the built-in agent ids (`default`, `executor`, `deep-executor`, `goal`, `__evo_agent__`, `__score__`, `__apply_agent__`), built-in skill ids (`agent`, `skill`, `workspace`, `cron`, `acp`, `send-file`, `aws-knowledge`, `web-search`, `self`, `halo`, `extension`).
87
+ - **Always overwritten** (platform-owned, refreshed when the template version moves): `~/.halo/global/{prompts,models,docs}/`, `INSTRUCTIONS.md`, the built-in agent ids (`default`, `executor`, `deep-executor`, `goal`, `__evo_agent__`, `__score__`, `__apply_agent__`), built-in skill ids (`agent`, `skill`, `workspace`, `cron`, `send-file`, `aws-knowledge`, `web-search`, `self`, `halo`, `extension`).
88
88
  - **Built-in agents** keep the user's `model:` and `context:` blocks on overwrite — the admin UI lets users change which model an agent uses and its context limits, and that choice survives upgrades.
89
89
  - **Optional skills** (`tavily-web-search`) install only when picked via `halo setup`; the opt-in list is `~/.halo/global/.installed-optional-skills`. Picked skills are force-overwritten alongside the always-overwritten set.
90
90
  - **Skill params in `halo setup`**: a newly picked optional skill walks all its declared params; for built-in skills setup asks only the `secret: true` ones (`listRequiredSkillsWithSecrets`, `setup-providers.ts`) — plain params such as `extension.params.hub_repo` / `web-search.params.fast_region` are set in Settings → Skills only.
@@ -99,7 +99,7 @@ Source: `packages/server/src/config.ts`
99
99
  |---|---|---|---|
100
100
  | `HALO_PORT` | `9527` | `config.yaml server.port` | Hono listen port |
101
101
  | `HALO_PASSWORD` | (none) | `config.yaml server.password` (scrypt hash) | Plaintext login password. When set, takes precedence over the stored hash and bypasses scrypt entirely — intended for Docker / systemd / CI. It also satisfies the **startup gate**: the server refuses to boot with no password configured, and env plaintext counts as a first-class credential there, so an env-only deployment (`halo setup -y && HALO_PASSWORD=... halo server start`, no stored hash) boots fine. The hash is set by `halo setup` for interactive installs. |
102
- | `HALO_CORS_ORIGINS` | empty (reflect any origin) | `config.yaml server.cors_origins` | CORS allowlist (comma-separated). Empty = reflect any incoming Origin so credentials work cross-origin. Set explicit list to enforce strict CORS. |
102
+ | `HALO_CORS_ORIGINS` | empty (reflect any origin) | `config.yaml server.cors_origins` | CORS allowlist (comma-separated, exact origins). Empty = reflect any incoming Origin **without** `Allow-Credentials` (header-token callers like `x-token` work; cookie-carrying cross-origin requests don't). A non-empty list = only those origins get CORS, with `Allow-Credentials` (e.g. a frontend behind an SSO that must send its cookie). Read at startup — a change needs a restart. Prefer a same-origin reverse proxy over listing origins (see `halo-city/README.md`). |
103
103
  | `HALO_FRONTEND_DIR` | auto (bundled `admin-out`, else `packages/admin/out`) | — | Static frontend dir; resolved against the current working directory |
104
104
  | `HALO_MAX_CONTEXT_TOKENS` | `272000` | — | Model max context |
105
105
  | `HALO_SHELL_TIMEOUT` | `120000` | `config.yaml timeout.shell_exec` | Shell command timeout (ms) |
@@ -202,7 +202,7 @@ Per-channel defaults: every channel session (Web, Telegram, Slack, Feishu, WeCom
202
202
  `grep` reads the first 512 bytes of each file looking for a null byte and skips binaries (`glob` matches paths only and never opens files).
203
203
 
204
204
  ### Tool result budget
205
- The orchestrator truncates tool results over 8000 chars and appends a `[Content truncated]` hint telling the agent to use `grep` for a targeted search. `activate_skill` results are exempt — a SKILL.md body is instructions, not data, and the built-in acp / cron / self skills exceed 8K.
205
+ The orchestrator truncates tool results over 8000 chars and appends a `[Content truncated]` hint telling the agent to use `grep` for a targeted search. `activate_skill` results are exempt — a SKILL.md body is instructions, not data, and the built-in cron / self skills exceed 8K.
206
206
 
207
207
  ## Session tools
208
208
 
@@ -284,13 +284,13 @@ Send a message to another session. Idle = immediate run, busy = queued + soft in
284
284
  | `target_session_id` | string | yes | Target session ID |
285
285
  | `message` | string | yes | Message content |
286
286
 
287
- **Busy = soft interrupt (merge-answer parity)**: when the target is busy, `query_session` enqueues the message **and** requests a soft interrupt (same as a user message arriving mid-turn) — the in-flight turn unwinds after its current tool, then every message that landed alongside it drains as **one merged turn**. So a sub-agent asked two questions while busy answers them **together**, not one-by-one — matching how root folds two user messages. No message is dropped. (Before 0.1.4 a busy `query_session` was pure no-interrupt enqueue, which made sub-agents reply one question at a time.)
287
+ **Busy = soft interrupt (merge-answer parity)**: when the target is busy, `query_session` enqueues the message **and** requests a soft interrupt (same as a user message arriving mid-turn) — the in-flight turn finishes its current batch of tool calls and unwinds before its next model call, then every message that landed alongside it drains as **one merged turn**. So a sub-agent asked two questions while busy answers them **together**, not one-by-one — matching how root folds two user messages. No message is dropped. (Before 0.1.4 a busy `query_session` was pure no-interrupt enqueue, which made sub-agents reply one question at a time.)
288
288
 
289
289
  **Queue cap (backpressure)**: when the target is busy, `query_session` is rejected with `{"code": 1, "error": "...message queue is full (N/max)..."}` once the target's queued **agent-sourced** messages reach `session.maxQueueSize` (default 256, settings `general.session.max_queue_size`). The cap counts **only agent→agent entries** — user messages share the same queue but are immune to backpressure (a human can't hand-type up to the cap, and counting them would let user chatter consume the agents' budget). `interrupt_session` is a deliberate action and bypasses the cap entirely.
290
290
 
291
291
  ### interrupt_session
292
292
 
293
- Equivalent to `query_session` **plus an immediate abort** of the in-flight turn. The message is enqueued and traced right away (exactly like `query_session`); aborting then makes the queue drain **now** rather than after the current turn finishes, so the enqueued message is folded into the very next merged turn. The abort is hard — it propagates to `shell_exec` and SIGTERMs a command mid-execution (contrast the soft interrupt `query_session` and a busy user message trigger, which wait for the current `tool_result`). For a **compound** command (`sleep 60 && …`) under `full` access, the kill reaches the real worker because the command runs as a **process-group leader** and the whole group is signalled — before 0.1.4 the abort only hit the wrapping `/bin/sh`, leaving the worker to orphan and run to completion (which made `interrupt_session` look like it didn't interrupt). See [session.md → Process-group kill on abort](../design/session.md#process-group-kill-on-abort). Conversation history is preserved (repaired, not discarded), and `interrupt_session` bypasses the `query_session` queue cap.
293
+ Equivalent to `query_session` **plus an immediate abort** of the in-flight turn. The message is enqueued and traced right away (exactly like `query_session`); aborting then makes the queue drain **now** rather than after the current turn finishes, so the enqueued message is folded into the very next merged turn. The abort is hard — it propagates to `shell_exec` and SIGTERMs a command mid-execution (contrast the soft interrupt `query_session` and a busy user message trigger, which wait for the current tool batch to finish). Of a cut parallel batch, the call that was running gets the do-not-retry `[tool execution interrupted …]` result and the calls that never started get a "was not run … safe to re-issue" result. For a **compound** command (`sleep 60 && …`) under `full` access, the kill reaches the real worker because the command runs as a **process-group leader** and the whole group is signalled — before 0.1.4 the abort only hit the wrapping `/bin/sh`, leaving the worker to orphan and run to completion (which made `interrupt_session` look like it didn't interrupt). See [session.md → Process-group kill on abort](../design/session.md#process-group-kill-on-abort). Conversation history is preserved (repaired, not discarded), and `interrupt_session` bypasses the `query_session` queue cap.
294
294
 
295
295
  | Arg | Type | Required | Description |
296
296
  |---|---|---|---|
@@ -299,7 +299,7 @@ Equivalent to `query_session` **plus an immediate abort** of the in-flight turn.
299
299
 
300
300
  ### stop_session
301
301
 
302
- Abort the current task of a running session. Any queued messages are **not** dropped — they are folded into the conversation history before the abort, so nothing said while the session was busy is lost. The session stays usable — later `query_session` calls continue the conversation.
302
+ Abort the current task of a running session. Any queued messages are **not** dropped — they are folded into the conversation history before the abort, so nothing said while the session was busy is lost. The session stays usable — later `query_session` calls continue the conversation. Refused with `{"code": 1}` (no stop) when `session_id` is the caller's own session or one of its ancestors — the stop would wait on the very turn making the call; to end its own work an agent just finishes its turn.
303
303
 
304
304
  | Arg | Type | Required | Description |
305
305
  |---|---|---|---|
@@ -307,7 +307,7 @@ Abort the current task of a running session. Any queued messages are **not** dro
307
307
 
308
308
  ### archive_session
309
309
 
310
- **Cascade** archive a session and every descendant. Aborts running work, clears queued messages. Archived sessions disappear from `session_list` and cannot be reached by `query_session`. Only use it when the whole subtree is done.
310
+ **Cascade** archive a session and every descendant. Aborts running work, clears queued messages. Archived sessions disappear from `session_list` and cannot be reached by `query_session`. Only use it when the whole subtree is done. Refused with `{"code": 1}` (nothing archived) when `session_id` is the caller's own session or one of its ancestors, for the same reason as `stop_session`.
311
311
 
312
312
  | Arg | Type | Required | Description |
313
313
  |---|---|---|---|
@@ -372,7 +372,7 @@ Cross-**workspace** dispatch on the same server: a "secretary" agent in workspac
372
372
 
373
373
  ### relay_send
374
374
 
375
- Dispatch a message to a session in another workspace. Creates the session if `session_id` does not exist there (root session, agent `agent_id` or the target workspace's default agent, description `Relay: <first 60 chars>`), stamps the target row's `reply_to` with `{ workspace: <caller ws>, sessionId: <caller session> }`, appends the raw message to the target's UI transcript and sends it prefixed `[channel: relay | from: <caller ws>]\n\n<message>` — so the target agent knows the message came from another workspace's agent, not the admin UI, and doesn't echo the tag. Busy target → the message is **queued + soft interrupt** (same as `query_session` / a user message mid-turn: the current tool finishes, then the queue drains as one merged turn), so follow-ups and corrections ride the same tool. Returns immediately.
375
+ Dispatch a message to a session in another workspace. Creates the session if `session_id` does not exist there (root session, agent `agent_id` or the target workspace's default agent, description `Relay: <first 60 chars>`), stamps the target row's `reply_to` with `{ workspace: <caller ws>, sessionId: <caller session> }`, appends the raw message to the target's UI transcript and sends it prefixed `[channel: relay | from: <caller ws>]\n\n<message>` — so the target agent knows the message came from another workspace's agent, not the admin UI, and doesn't echo the tag. Busy target → the message is **queued + soft interrupt** (same as `query_session` / a user message mid-turn: the current batch of tool calls finishes, then the queue drains as one merged turn), so follow-ups and corrections ride the same tool. Returns immediately. When `workspace` is the caller's own workspace, a `session_id` naming the caller itself or one of its ancestors is refused (`{"code": 1}`, nothing sent, `reply_to` untouched) — its report would come back into the caller's own tree; other sessions in the same workspace are fine.
376
376
 
377
377
  | Arg | Type | Required | Description |
378
378
  |---|---|---|---|
@@ -405,7 +405,7 @@ Body capped at `limits.autoReportMax` (head-kept) with a `[Report truncated: N c
405
405
 
406
406
  ### relay_interrupt
407
407
 
408
- `relay_send` **plus a hard abort** of the target's in-flight turn — `interrupt_session` semantics across workspaces: the message is enqueued first, and if the target was busy (`state === 'queued'`) its turn is aborted immediately (propagates to `shell_exec`, SIGTERMs the process group) so the drain picks the message up now instead of after the current step. Enqueue-then-abort order matters: the finally never sees an empty queue and so never fires a spurious relay report. Idle target → behaves exactly like `relay_send` (`interrupted: false`). Unlike `relay_send` it does **not** create a missing session (`{"code": 1, "error": "session not found"}`) — there is nothing to interrupt. Use it when the target is heading the wrong way and waiting for its current step is not acceptable; for ordinary follow-ups prefer `relay_send`.
408
+ `relay_send` **plus a hard abort** of the target's in-flight turn — `interrupt_session` semantics across workspaces: the message is enqueued first, and if the target was busy (`state === 'queued'`) its turn is aborted immediately (propagates to `shell_exec`, SIGTERMs the process group) so the drain picks the message up now instead of after the current step. Enqueue-then-abort order matters: the finally never sees an empty queue and so never fires a spurious relay report. Idle target → behaves exactly like `relay_send` (`interrupted: false`). Unlike `relay_send` it does **not** create a missing session (`{"code": 1, "error": "session not found"}`) — there is nothing to interrupt. Use it when the target is heading the wrong way and waiting for its current step is not acceptable; for ordinary follow-ups prefer `relay_send`. Same own-workspace self / ancestor refusal as `relay_send`.
409
409
 
410
410
  | Arg | Type | Required | Description |
411
411
  |---|---|---|---|
@@ -417,7 +417,7 @@ Returns `{ "code": 0, "workspace", "session_id", "state": "running" | "queued",
417
417
 
418
418
  ### relay_stop
419
419
 
420
- Cascades `stopSession` on the target session and its sub-agents. If the target was mid-turn the caller still receives a relay report describing where it was cut off (the stop ends the turn → finally → `deliverRelayReport`, with the partial trace as body).
420
+ Cascades `stopSession` on the target session and its sub-agents. If the target was mid-turn the caller still receives a relay report describing where it was cut off (the stop ends the turn → finally → `deliverRelayReport`, with the partial trace as body). When `workspace` is the caller's own workspace, a `session_id` naming the caller itself or one of its ancestors is refused (`{"code": 1}`, no stop) — same rule as `stop_session`; the same id in another workspace is a different session and stops normally.
421
421
 
422
422
  | Arg | Type | Required | Description |
423
423
  |---|---|---|---|
@@ -22,7 +22,7 @@ Aimed at Halo end-users. Pick the doc that matches your situation.
22
22
  - [Feishu / Lark](channels/feishu.md) — long-connect with appId + appSecret
23
23
  - [WeCom](channels/wecom.md) — 企业微信 intelligent bot, long-connect with Bot ID + Secret
24
24
  - [WeChat](channels/wechat.md) — QR-scan iLink bot
25
- - [ACP](channels/acp.md) — Claude Code or other ACP clients driving a remote halo
25
+ - [ACP](channels/acp.md) — Zed / JetBrains or other ACP clients driving a remote halo
26
26
 
27
27
  ## Related docs
28
28
 
@@ -12,7 +12,7 @@ This folder has one onboarding guide per channel. Pick the one you want and foll
12
12
  | [Feishu / Lark](feishu.md) | Chinese enterprise teams | Feishu open-platform App (`appId` + `appSecret`) + long connection | WebSocket (long-connect) |
13
13
  | [WeCom](wecom.md) | Chinese enterprise teams on 企业微信 (single chat + groups) | 智能机器人 (Bot ID + Secret) + long connection | WebSocket (long-connect) |
14
14
  | [WeChat](wechat.md) | China-side personal use; mobile-first | iLink-style bot, login by scanning a QR | Long-poll |
15
- | [ACP](acp.md) | Claude Code (or other ACP clients) driving a remote halo | Web token + adapter CLI | JSON-RPC over stdio |
15
+ | [ACP](acp.md) | An ACP-speaking IDE (Zed / JetBrains / …) driving a remote halo | Web token + adapter CLI | JSON-RPC over stdio |
16
16
 
17
17
  ## How channels are wired
18
18
 
@@ -28,7 +28,7 @@ This folder has one onboarding guide per channel. Pick the one you want and foll
28
28
  SessionManager (per workspace)
29
29
  ```
30
30
 
31
- Each channel handler is a thin adapter between its native protocol and halo's `SessionManager`. Slash commands (`/session` `/agent` `/skill` `/workspace` `/cron` `/acp` `/evo` `/extension` `/help`) are shared across all of them — see `channels/shared/commands.ts`.
31
+ Each channel handler is a thin adapter between its native protocol and halo's `SessionManager`. Slash commands (`/session` `/agent` `/skill` `/workspace` `/cron` `/evo` `/extension` `/help`) are shared across all of them — see `channels/shared/commands.ts`.
32
32
 
33
33
  The ACP adapter is **not** a channel — it's a stdio bridge that translates ACP JSON-RPC into the web channel's HTTP+SSE. Counted here only because users go through the same "set up an account, get a token" flow.
34
34
 
@@ -62,7 +62,7 @@ When in doubt start at `readonly` and raise it later. The setting is on the **ac
62
62
 
63
63
  - Already know which channel you want → click the row above
64
64
  - New to halo and just want to chat in a browser → [Web](web.md)
65
- - Want to run halo from your IDE (Claude Code etc.) → [ACP](acp.md)
65
+ - Want to run halo from your IDE (Zed / JetBrains) → [ACP](acp.md)
66
66
  - Setting up a team workspace → [Slack](slack.md), [Feishu](feishu.md) or [WeCom](wecom.md)
67
67
 
68
68
  For the design rationale and protocol details (not user-facing), see [docs/design/](../../design/) — one file per channel: `web.md`, `telegram.md`, `slack.md`, `feishu.md`, `wecom.md`, `wechat.md`.
@@ -1,6 +1,6 @@
1
1
  # ACP (Agent Client Protocol)
2
2
 
3
- Drive a halo server from any [ACP](https://github.com/zed-industries/agent-client-protocol)-speaking client. The most common use is **Claude Code on a developer's laptop, talking to a halo agent on EC2** — but any ACP client works.
3
+ Drive a halo server from any [ACP](https://github.com/zed-industries/agent-client-protocol)-speaking client. The most common use is **an ACP-capable editor (Zed, a JetBrains IDE) on a developer's laptop, talking to a halo agent on EC2** — but any ACP client works.
4
4
 
5
5
  ACP isn't a channel of its own. The `halo acp` command is a **stdio bridge** that translates ACP JSON-RPC into the Web channel's HTTP+SSE. So setup = "create a Web account, then point an adapter at it."
6
6
 
@@ -8,7 +8,7 @@ ACP isn't a channel of its own. The `halo acp` command is a **stdio bridge** tha
8
8
 
9
9
  ```
10
10
  [Mac] [EC2]
11
- Claude Code halo server (port 9527)
11
+ Zed / JetBrains IDE halo server (port 9527)
12
12
  │ ACP / JSON-RPC over stdio │
13
13
  ▼ │
14
14
  halo acp adapter ────HTTP/SSE────────────────▶│
@@ -19,8 +19,7 @@ halo acp adapter ────HTTP/SSE──────────────
19
19
  ```
20
20
 
21
21
  Use ACP when:
22
- - You want to use Claude Code locally as the chat UI but the agent's tools / workspace live on a remote machine
23
- - You want one halo server to delegate to **another** halo server on a different host (the `/acp add` flow below)
22
+ - You want to use your editor (Zed, a JetBrains IDE) locally as the chat UI but the agent's tools / workspace live on a remote machine
24
23
  - You're integrating with any tool that already speaks ACP
25
24
 
26
25
  If you just want a browser UI, use the [Web](web.md) channel directly — ACP is overkill.
@@ -61,58 +60,82 @@ Each adapter process binds to **one** workspace. To drive multiple workspaces co
61
60
 
62
61
  The adapter reads/writes JSON-RPC frames on **stdin / stdout** (one message per line, no LSP-style framing). **Stderr** is reserved for human-readable diagnostics — never parse it.
63
62
 
64
- ### Wiring into Claude Code
65
-
66
- Claude Code lets you register a custom agent. Use its `claude-code config agent add` command (see Claude Code's own docs for the exact incantation) and point it at the `halo acp …` invocation above.
67
-
68
- After registration, you can launch a session in Claude Code that streams to the remote halo server transparently — Claude Code thinks it's talking to a local agent, halo thinks it's talking to a Web-channel client.
69
-
70
- ## Step 3 — (Halo-to-halo) bind a remote with `/acp add`
71
-
72
- The most common use of this adapter isn't a third-party ACP client — it's **another halo agent** delegating out to a remote halo workspace. Halo ships a builtin `acp` skill for this (slash command `/acp`, full access). Type `/acp add` — or just ask in chat (e.g. "add an ACP binding to my other halo").
73
-
74
- It walks you through `(label, host, port, workspace, token)` and **stamps out a new skill** named `ask-<label>` containing:
75
-
76
- - `SKILL.md` — slash command `/ask-<label>`, instructions tailored to this remote
77
- - `config.yaml` — declares the binding's params so admin Settings shows a form
78
- - `ask.py` — bundled JSON-RPC ↔ stdio helper (one copy per binding, intentional — keeps `/workspace share` bundles self-contained)
79
-
80
- It also writes the connection values into `settings.yaml` (workspace or global, you pick).
81
-
82
- After install, the local agent can do `shell_exec: python3 .../ask-<label>/ask.py "<question>"` and halo's runtime substitutes the configured values. **Multiple bindings coexist** — each gets its own slash command, settings namespace, and Admin Settings page. `/acp list` shows the bindings you've generated.
83
-
84
- To remove a binding: `/acp remove` (deletes the skill directory and points out the leftover `ask-<label>:` block in `settings.yaml`).
85
-
86
- This is the **only** supported way to set up a halo-to-halo binding — there's no generic single-target `ask-acp-agent` skill, because per-binding namespaces (one token-host-workspace triple per skill id) are required for multi-remote use.
87
-
88
- ### Direct asks — `/acp kiro` / `/acp claude`
63
+ ### Wiring into Zed
64
+
65
+ Register the adapter as a custom agent in Zed's `settings.json` (Agent Settings → External Agents → Add Agent → Add Custom Agent opens it for you). See [Zed's External Agents docs](https://zed.dev/docs/ai/external-agents):
66
+
67
+ ```json
68
+ {
69
+ "agent_servers": {
70
+ "halo": {
71
+ "type": "custom",
72
+ "command": "halo",
73
+ "args": [
74
+ "acp",
75
+ "--host", "my-ec2-or-localhost",
76
+ "--port", "9527",
77
+ "--token", "<web-token-from-step-1>",
78
+ "--workspace", "/abs/path/on/server"
79
+ ],
80
+ "env": {}
81
+ }
82
+ }
83
+ }
84
+ ```
89
85
 
90
- Bindings are for **remote halo servers**. For agents on the **same machine** no binding is needed — the `acp` skill talks to them directly, zero config:
86
+ ### Wiring into JetBrains IDEs
87
+
88
+ In the AI Chat tool window click the menu button in the upper-right corner and choose **Add Custom Agent**; the IDE creates and opens `~/.jetbrains/acp.json`. See [JetBrains' ACP docs](https://www.jetbrains.com/help/ai-assistant/acp.html):
89
+
90
+ ```json
91
+ {
92
+ "agent_servers": {
93
+ "halo": {
94
+ "command": "halo",
95
+ "args": [
96
+ "acp",
97
+ "--host", "my-ec2-or-localhost",
98
+ "--port", "9527",
99
+ "--token", "<web-token-from-step-1>",
100
+ "--workspace", "/abs/path/on/server"
101
+ ],
102
+ "env": {}
103
+ }
104
+ }
105
+ }
106
+ ```
91
107
 
92
- - `/acp claude <question>` — local Claude Code (via npm `@agentclientprotocol/claude-agent-acp`)
93
- - `/acp kiro <question>` — local Kiro (via `kiro-cli acp`)
108
+ If the IDE can't find `halo` on its `PATH`, use the absolute path to the binary as `command`.
94
109
 
95
- The question is passed verbatim — including the other agent's own slash commands (e.g. `/acp kiro /model <full-model-id>` switches Kiro's model). Both coexist with `/ask-<label>` bindings.
110
+ After registration, pick the agent in the editor's agent panel and chat — the editor thinks it's talking to a local agent, halo thinks it's talking to a Web-channel client.
96
111
 
97
112
  ## ACP method coverage
98
113
 
114
+ The adapter speaks ACP protocol v1 and passes the [ACP TCK](https://github.com/agentclientprotocol/acp-tck) v1 suite (verdict CONFORMANT).
115
+
99
116
  | Method | Implemented | Notes |
100
117
  |---|---|---|
101
- | `initialize` | ✅ | Declares `protocolVersion: 1`, `loadSession: true`, no auth methods |
118
+ | `initialize` | ✅ | Declares `protocolVersion: 1`, `loadSession: true`, `sessionCapabilities.list`, image + embedded-context prompts, no auth methods, and `agentInfo` (`halo` + adapter version) |
102
119
  | `authenticate` | ✅ (no-op) | Token is passed via launch flags; ACP-side auth has nothing to do |
103
120
  | `session/new` | ✅ | Calls `POST /api/web/sessions`; halo mints `web_<accountId>_<ts>_<rand>` and creates the row immediately |
104
- | `session/load` | ✅ | Verifies the supplied id still exists on the halo server, then registers it locally |
105
- | `session/prompt` | ✅ | Forwards text + image content blocks. Resource / embedded-context blocks log a stderr warning and are dropped (see "Reverse fs" below) |
106
- | `session/cancel` | ✅ | Aborts the in-flight HTTP/SSE stream and POSTs `/web/stop` |
121
+ | `session/load` | ✅ | Replays the session's active log into the editor (your messages, the agent's thinking, tool calls with their output, replies), then resumes it. That's the whole conversation until the session is archived; after archiving only the newest part |
122
+ | `session/list` | ✅ | Lists this token's own conversations, newest first — what an editor's session history / resume picker shows |
123
+ | `session/prompt` | ✅ | Text and images go through as-is. A file the editor attaches with its content (an @-mention) is inlined into the message as `[resource: <uri>]` plus the content in a code block; a binary attachment becomes `[binary resource omitted: <uri>]`; a file *link* becomes `[resource link: <name> <uri>]` — only a pointer, since the server can't open a path on your laptop. Audio is not supported (dropped with a stderr warning) |
124
+ | `session/cancel` | ✅ | Stops the turn server-side; the interrupted tool calls still show up before the prompt ends as `cancelled` |
125
+ | `session/resume`, modes, config options, `session/delete` / `close`, `logout` | ❌ | Not advertised in `initialize`; calling them anyway gets "method not found" |
107
126
  | Reverse `fs/*` | ❌ | See "Reverse fs" below |
108
127
  | Reverse `terminal/*` | ❌ | Same reasoning |
109
128
  | `requestPermission` | ❌ | Halo has its own access-level system at the channel-account level |
110
129
 
130
+ Tool calls show in the editor with a kind (read / edit / execute / search / fetch / other) and a one-line title such as `shell_exec: ls -la`. They carry no file locations — the paths are on the server, the editor couldn't open them. Your editor's own MCP servers are not connected to the halo agent.
131
+
132
+ If the connection drops mid-turn (proxy timeout, network blip, server restart), the adapter re-attaches on its own and fills in whatever text it missed; after 5 failed attempts it ends the turn with `[adapter error] connection lost`. Sending a prompt while the session is already busy (another client is mid-turn on it) queues it on the server; the adapter waits for that run and returns the reply to your message when it finishes.
133
+
111
134
  ### Session id model
112
135
 
113
- ACP `sessionId` **is** the halo session id — there's no extra mapping layer. `session/new` asks the server to mint one (`POST /api/web/sessions`); it lands in the token's own `web_<accountId>_` namespace and the `agent_sessions` row exists immediately, so readonly / workspace tokens can drive it too. The ACP client persists ids itself; the adapter holds no on-disk state. Losing the adapter's in-memory map on restart is harmless because the conversation lives on the halo server.
136
+ ACP `sessionId` **is** the halo session id — there's no extra mapping layer. `session/new` asks the server to mint one (`POST /api/web/sessions`); it lands in the token's own `web_<accountId>_` namespace and the `agent_sessions` row exists immediately, so readonly / workspace tokens can drive it too. The adapter holds no on-disk state; losing its in-memory map on restart is harmless because the conversation lives on the halo server.
114
137
 
115
- The ACP client is the source of truth for "which sessions are mine" — a Mac-side Claude Code knows about *its* sessions, the EC2-side halo agent doesn't need to enumerate them.
138
+ `session/list` asks the server for the token's own root conversations (every session under its `web_<accountId>_` prefix — including ones a browser client made with the same token; sub-agent sessions excluded). It's scoped to the token even for a `full` token. Each entry's `cwd` is the server-side workspace path, so if your editor filters the list by its local project directory it gets an empty list — that's expected.
116
139
 
117
140
  ## Multi-workspace
118
141
 
@@ -131,7 +154,7 @@ ACP optionally lets the agent (running on the server) request files from the cli
131
154
  1. The Web channel is HTTP + SSE — a one-way stream. Reverse fs would need WebSocket or long-poll
132
155
  2. Halo agents currently use `file_read` / `file_write` against the **server's** workspace; supporting reverse fs would need a parallel toolset
133
156
 
134
- For now: if you want the agent to see a Mac-side file, paste it into the prompt. The adapter logs a stderr warning when it sees a `resource` content block in `session/prompt`, so the failure mode is obvious.
157
+ What works instead: attach the file with its content (e.g. an @-mention in your editor) — the adapter inlines it into the prompt, so the agent reads it. A plain file link only tells the agent the name and path; it can't open it. Or paste the content into the prompt.
135
158
 
136
159
  ## Common problems
137
160
 
@@ -140,8 +163,10 @@ For now: if you want the agent to see a Mac-side file, paste it into the prompt.
140
163
  | Adapter exits immediately on launch | Missing required flag, or the token / host are wrong. Check stderr |
141
164
  | `401` on first prompt | Token typo, or token was deleted from admin |
142
165
  | `403` when launching with `--workspace /some/other/path` | Token is `readonly` / `workspace` access — use a `full` token or omit `--workspace` |
143
- | Tool calls don't appear in Claude Code | Expected — halo doesn't translate every event back as ACP `tool_call`. See [docs/dev/acp-adapter.md](../../dev/acp-adapter.md) for the full mapping |
144
- | Two `session/prompt` calls on the same id, second hangs | Halo queues messages when a session is busy; ACP adapter ends the response with `[queued — session busy]`. Wait for the first to finish |
166
+ | Tool call shows no file link / can't jump to the file | Expected — tool paths are on the server, so the adapter sends no `locations`. See [docs/dev/acp-adapter.md](../../dev/acp-adapter.md) for the full mapping |
167
+ | Second prompt rejected while the first is still running | ACP allows one prompt per session at a time — cancel the first, or wait for it |
168
+ | Reply text appears again behind `[reconnected — full reply]` | The stream dropped mid-reply and the missed part couldn't be appended cleanly, so the adapter resent the whole reply. A proxy cutting long streams is the usual cause |
169
+ | Session history picker is empty | If the editor filters by its local project directory, the list comes back empty: halo sessions live under the **server** workspace path |
145
170
  | `/workspace switch <path>` worked but other tokens broke | You changed the db-level default. Switch back with another `/workspace switch`, or stop using slash commands from the adapter |
146
171
 
147
172
  ## Reference
@@ -130,7 +130,7 @@ Same set as the other channels — type as plain text, **in a 1:1 DM only** (in
130
130
  | `/agent <verb>` | Manage agents (`list` / `switch` / `desc` open to all; `delete` full; `create` / `update` via skill, full) |
131
131
  | `/skill <verb>` | Manage skills (`list` / `desc` open; `disable` / `enable` workspace; `delete` full; `create` / `update` via skill, full) |
132
132
  | `/workspace <verb>` | Workspace: `info` (all) / `switch <path>` (full) / `setup` / `tidy` (workspace) / `share` (full) |
133
- | `/cron` `/acp` `/extension` | Skill-backed object commands (full access); `/evo [hint]` queues a self-evolution run |
133
+ | `/cron` `/extension` | Skill-backed object commands (full access); `/evo [hint]` queues a self-evolution run |
134
134
  | `/help` | List commands — object commands show only the verbs you can run |
135
135
 
136
136
  ## Cron jobs targeting Feishu
@@ -132,7 +132,7 @@ Same set as the other channels, but **only in a DM with the bot** — in a chann
132
132
  | `/agent <verb>` | Manage agents (`list` / `switch` / `desc` open to all; `delete` full; `create` / `update` via skill, full) |
133
133
  | `/skill <verb>` | Manage skills (`list` / `desc` open; `disable` / `enable` workspace; `delete` full; `create` / `update` via skill, full) |
134
134
  | `/workspace <verb>` | Workspace: `info` (all) / `switch <path>` (full) / `setup` / `tidy` (workspace) / `share` (full) |
135
- | `/cron` `/acp` `/extension` | Skill-backed object commands (full access); `/evo [hint]` queues a self-evolution run |
135
+ | `/cron` `/extension` | Skill-backed object commands (full access); `/evo [hint]` queues a self-evolution run |
136
136
  | `/help` | List commands — object commands show only the verbs you can run |
137
137
 
138
138
  ## Cron jobs targeting Slack
@@ -78,7 +78,7 @@ If nothing happens, check halo server logs for `[Telegram]` lines.
78
78
  | `/agent <verb>` | Manage agents (`list` / `switch` / `desc` open to all; `delete` full; `create` / `update` via skill, full) |
79
79
  | `/skill <verb>` | Manage skills (`list` / `desc` open; `disable` / `enable` workspace; `delete` full; `create` / `update` via skill, full) |
80
80
  | `/workspace <verb>` | Workspace: `info` (all) / `switch <path>` (full) / `setup` / `tidy` (workspace) / `share` (full) |
81
- | `/cron` `/acp` `/extension` | Skill-backed object commands (full access); `/evo [hint]` queues a self-evolution run |
81
+ | `/cron` `/extension` | Skill-backed object commands (full access); `/evo [hint]` queues a self-evolution run |
82
82
  | `/help` | List commands — object commands show only the verbs you can run |
83
83
 
84
84
  ## Cron jobs targeting Telegram
@@ -36,19 +36,20 @@ curl -N -H "x-token: $TOKEN" -H "Content-Type: application/json" \
36
36
  http://localhost:9527/api/web/chat
37
37
  ```
38
38
 
39
- Response is a stream of `data: {json}\n\n` SSE frames:
39
+ Response is a standard SSE stream: `data: {json}` event lines, plus a `: keepalive` comment line every 15 s so an idle proxy doesn't cut the connection during a long tool call:
40
40
 
41
41
  ```
42
42
  data: {"type":"session","sessionId":"web_abc123_m1xyz"}
43
43
  data: {"type":"thinking","text":"..."}
44
- data: {"type":"tool_call","toolName":"file_read","toolInput":{...}}
45
- data: {"type":"tool_result","toolName":"file_read","result":"..."}
44
+ data: {"type":"tool_call","toolName":"file_read","toolUseId":"toolu_01…","toolInput":{...}}
45
+ : keepalive
46
+ data: {"type":"tool_result","toolName":"file_read","toolUseId":"toolu_01…","result":"..."}
46
47
  data: {"type":"stream","text":"Hello! "}
47
48
  data: {"type":"stream","text":"How can I help?"}
48
49
  data: {"type":"complete"}
49
50
  ```
50
51
 
51
- Parse the `type` field on each event to render text vs tool calls vs completion.
52
+ Parse the `type` field on each event to render text vs tool calls vs completion. **Only lines starting with `data:` are events** — skip everything else (the keepalive comment, blank separators). `EventSource` does this for you; a hand-rolled parser that splits on `\n\n` and slices off `data: ` will break on the keepalive. `toolUseId` pairs a `tool_result` with its `tool_call` when several run at once (it can be empty for some model providers — then pair with the most recent call).
52
53
 
53
54
  ### Other endpoints
54
55
 
@@ -56,12 +57,13 @@ Parse the `type` field on each event to render text vs tool calls vs completion.
56
57
  |---|---|---|
57
58
  | `POST /api/web/chat` | Send a message; SSE response |
58
59
  | `POST /api/web/sessions` | Mint a new root session in the token's namespace; returns `{sessionId}` |
60
+ | `GET /api/web/sessions?cursor=…` | List the token's own conversations, newest first, 50 per page → `{workspace, sessions: [{sessionId, title, updatedAt}], nextCursor}`; pass `nextCursor` back as `cursor` for the next page (`null` = last page) |
59
61
  | `POST /api/web/stop` | Cancel the running task |
60
- | `GET /api/web/history` | Fetch session message history |
61
- | `GET /api/web/subscribe` | Reconnect to a running session's SSE stream |
62
+ | `GET /api/web/history` | Fetch session message history (optional `since=<epoch ms>`: only rows from then on) |
63
+ | `GET /api/web/subscribe` | Reconnect to a running session's SSE stream (an idle session answers with one `complete` straight away) |
62
64
  | `GET /api/web/file?path=…` | Fetch a file from the bound workspace (path relative to it; `.halo` runtime state is refused) |
63
65
 
64
- All six accept the same auth header.
66
+ All seven accept the same auth header.
65
67
 
66
68
  ### Per-request overrides
67
69
 
@@ -102,7 +104,7 @@ Slash commands are intercepted before they reach the agent — same set as every
102
104
  | `/agent <verb>` | Manage agents (`list` / `switch` / `desc` open to all; `delete` full; `create` / `update` via skill, full) |
103
105
  | `/skill <verb>` | Manage skills (`list` / `desc` open; `disable` / `enable` workspace; `delete` full; `create` / `update` via skill, full) |
104
106
  | `/workspace <verb>` | Workspace: `info` (all) / `switch <path>` (full) / `setup` / `tidy` (workspace) / `share` (full) |
105
- | `/cron` `/acp` `/extension` | Skill-backed object commands (full access); `/evo [hint]` queues a self-evolution run |
107
+ | `/cron` `/extension` | Skill-backed object commands (full access); `/evo [hint]` queues a self-evolution run |
106
108
  | `/help` | List commands — object commands show only the verbs you can run |
107
109
 
108
110
  Send a slash command exactly like a normal message — the server detects the leading `/`.
@@ -77,7 +77,7 @@ Same set as the other channels — type as plain text in your WeChat DM with the
77
77
  | `/agent <verb>` | Manage agents (`list` / `switch` / `desc` open to all; `delete` full; `create` / `update` via skill, full) |
78
78
  | `/skill <verb>` | Manage skills (`list` / `desc` open; `disable` / `enable` workspace; `delete` full; `create` / `update` via skill, full) |
79
79
  | `/workspace <verb>` | Workspace: `info` (all) / `switch <path>` (full) / `setup` / `tidy` (workspace) / `share` (full) |
80
- | `/cron` `/acp` `/extension` | Skill-backed object commands (full access); `/evo [hint]` queues a self-evolution run |
80
+ | `/cron` `/extension` | Skill-backed object commands (full access); `/evo [hint]` queues a self-evolution run |
81
81
  | `/help` | List commands — object commands show only the verbs you can run |
82
82
  | `/qr [level]` | Generate an invite QR (`full`-access bot only; `level` = `readonly` (default) / `workspace` / `full`) |
83
83
 
@@ -105,7 +105,7 @@ Same set as the other channels — type as plain text in a **single chat** (comm
105
105
  | `/agent <verb>` | Manage agents (`list` / `switch` / `desc` open to all; `delete` full; `create` / `update` via skill, full) |
106
106
  | `/skill <verb>` | Manage skills (`list` / `desc` open; `disable` / `enable` workspace; `delete` full; `create` / `update` via skill, full) |
107
107
  | `/workspace <verb>` | Workspace: `info` (all) / `switch <path>` (full) / `setup` / `tidy` (workspace) / `share` (full) |
108
- | `/cron` `/acp` `/extension` | Skill-backed object commands (full access); `/evo [hint]` queues a self-evolution run |
108
+ | `/cron` `/extension` | Skill-backed object commands (full access); `/evo [hint]` queues a self-evolution run |
109
109
  | `/help` | List commands — object commands show only the verbs you can run |
110
110
 
111
111
  ## Cron jobs targeting WeCom
@@ -68,7 +68,6 @@ Most commands are noun-verb **object commands**: `/<obj> <verb> [args]`. Bare `/
68
68
  | `/skill <verb>` | Manage skills — `list` / `desc` (built-in, open to all) · `disable` / `enable` (built-in, workspace access) · `delete` (built-in, full access) · `create` / `update` (handled by the `skill` skill, full access) |
69
69
  | `/workspace <verb>` | Manage the workspace — `info` (built-in, open to all) · `switch <path>` (built-in, full access) · `setup` / `tidy` (workspace skill, workspace access; init / reorganize `.halo/` INDEX.md / INSTRUCTIONS.md / memory/) · `share` (workspace skill, full access; export a shareable bundle) |
70
70
  | `/cron <verb>` | Scheduled agent runs — `create` / `list` / `update` / `enable` / `disable` / `delete` (cron skill, full access) |
71
- | `/acp <verb>` | Talk to other agents over ACP — `kiro <q>` / `claude <q>` ask a local agent directly; `add` / `list` / `remove` manage generated `ask-<label>` bindings (acp skill, full access) |
72
71
  | `/extension <verb>` | Canvas preview extensions — `install <id\|zip\|url>` / `list` / `remove <id>` (extension skill, full access) |
73
72
  | `/evo [hint]` | Queue a self-evolution run on this session (full access only) |
74
73
  | `/help` | List every command — object commands only show the verbs you can run |
@@ -143,7 +143,6 @@ Multi-turn conversation. Supports all standard Halo slash commands:
143
143
  | `/workspace switch <path>` | Switch workspace (full access only) |
144
144
  | `/workspace setup` / `/workspace tidy` / `/workspace share` | Set up / tidy the `.halo/` knowledge files, or export a shareable bundle (workspace skill) |
145
145
  | `/cron <verb>` | Manage scheduled agent runs: `create` / `list` / `update` / `enable` / `disable` / `delete` |
146
- | `/acp <verb>` | Ask other agents over ACP (`kiro <q>` / `claude <q>`) and manage `ask-*` bindings (`add` / `list` / `remove`) |
147
146
  | `/extension <verb>` | Canvas preview extensions: `install` / `list` / `remove`; `models` updates the model provider list from the hub (full access only) |
148
147
  | `/evo [hint]` | Queue a self-evolution run on this session (full access only) |
149
148
  | `/quit` | Exit |
@@ -181,7 +180,7 @@ The line under the input box:
181
180
  | `Ctrl+W` / `Alt+Backspace` | Delete the word before the cursor |
182
181
  | `Ctrl+U` / `Ctrl+K` | Delete to start / to end of input |
183
182
 
184
- On resume (`-s <id>`, or the default latest session), the TUI replays the session's prior conversation on screen so you see where you left off. `shell_exec` output is shown inline by default (other tools' output needs `-v`). Tool lines show a short argument summary and duration, e.g. `⚙ file_read hello.txt 4ms`.
183
+ On resume (`-s <id>`, or the default latest session), the TUI replays the session's prior conversation on screen so you see where you left off — only the last 100 messages; anything older collapses into a first line `── N earlier messages hidden · ctrl+o for the full log ──`. `shell_exec` output is shown inline by default (other tools' output needs `-v`). Tool lines show a short argument summary and duration, e.g. `⚙ file_read hello.txt 4ms`.
185
184
 
186
185
  **Multi-line paste**: pasting text with newlines (or >800 chars) collapses into a compact placeholder like `[#1 pasted 3 lines]` in the input; you can keep typing around it, and the original text is expanded back in full on submit. Pasted content is inserted at the cursor position.
187
186
 
@@ -79,7 +79,7 @@ After `halo setup`:
79
79
 
80
80
  **When does "refreshed on upgrade" actually run?** `halo setup` always re-runs the seed; the server's startup check — and the same check at the start of `halo cli` / `halo tui` — also re-runs it automatically when `~/.halo/global/.template-version` is behind the bundled `TEMPLATE_VERSION`. So the routine flow `halo upgrade && halo server restart` is enough — no need to remember `halo setup` — and a CLI-only user just runs `halo cli` / `halo tui` after `halo upgrade`.
81
81
 
82
- **Server-refreshed**: `INSTRUCTIONS.md`, `prompts/`, `models/`, `docs/`, the built-in agent ids (`default`, `executor`, `deep-executor`, `goal`, `__evo_agent__`, `__score__`, `__apply_agent__`), and the built-in skill ids (`agent`, `skill`, `workspace`, `cron`, `acp`, `send-file`, `self`, `aws-knowledge`, `web-search`, `halo`, `extension`). To customize one, copy it into the workspace scope (`<project>/.halo/...`) — workspace replaces global at runtime.
82
+ **Server-refreshed**: `INSTRUCTIONS.md`, `prompts/`, `models/`, `docs/`, the built-in agent ids (`default`, `executor`, `deep-executor`, `goal`, `__evo_agent__`, `__score__`, `__apply_agent__`), and the built-in skill ids (`agent`, `skill`, `workspace`, `cron`, `send-file`, `self`, `aws-knowledge`, `web-search`, `halo`, `extension`). To customize one, copy it into the workspace scope (`<project>/.halo/...`) — workspace replaces global at runtime.
83
83
 
84
84
  **Never overwritten**: anything else under `agents/` or `skills/` (your own creations), and everything under `secrets/`.
85
85
 
@@ -30,7 +30,7 @@ Putting those in AGENT.md would bloat the system prompt. Skills use **progressiv
30
30
 
31
31
  Workspace overrides global (same id).
32
32
 
33
- **Built-in skills** (`agent`, `skill`, `workspace`, `cron`, `acp`, `send-file`, `self`, `aws-knowledge`, `web-search`, `halo`, `extension`) are server-shipped and **force-overwritten on every server startup**. Local edits to these directories under `~/.halo/global/skills/` will be lost. To customize one, copy it into `<project>/.halo/skills/<id>/` (workspace replaces global) and edit there. Other skills under `~/.halo/global/skills/` — including any the user created via the admin UI — are untouched by the seeder.
33
+ **Built-in skills** (`agent`, `skill`, `workspace`, `cron`, `send-file`, `self`, `aws-knowledge`, `web-search`, `halo`, `extension`) are server-shipped and **force-overwritten on every server startup**. Local edits to these directories under `~/.halo/global/skills/` will be lost. To customize one, copy it into `<project>/.halo/skills/<id>/` (workspace replaces global) and edit there. Other skills under `~/.halo/global/skills/` — including any the user created via the admin UI — are untouched by the seeder.
34
34
 
35
35
  ## SKILL.md format
36
36
 
@@ -154,7 +154,6 @@ Halo seeds these skills on every startup (the ids in `BUILTIN_SKILL_IDS`, `packa
154
154
  | skill | Create / update skills — backs the `create` / `update` verbs of `/skill` |
155
155
  | workspace | Workspace maintenance — backs `/workspace setup` / `tidy` (init / reorganize `.halo/` INDEX.md / INSTRUCTIONS.md / memory/) and `/workspace share` (export a shareable bundle) |
156
156
  | cron | Create / list / update / enable / disable / delete scheduled agent runs — backs `/cron` |
157
- | acp | Talk to other agents over ACP (`/acp kiro\|claude <q>`) and manage `ask-<label>` bindings for halo-to-halo delegation (`/acp add\|list\|remove`) |
158
157
  | send-file | Deliver an image/video/file as a channel attachment by emitting `MEDIA:<absolute_path>` — works on Web / WeChat / Telegram / Slack / Feishu (no command; model-activated, workspace access) |
159
158
  | self | The agent's own visual space (`.halo/canvas/self.html`) for self-expression (no command; model-activated, full access) |
160
159
  | aws-knowledge | Query the official AWS Knowledge MCP server for up-to-date AWS docs (no command, `user-invocable: false`; model auto-activates) |
@@ -8,6 +8,7 @@ The primary surface for talking to an agent.
8
8
  - Dropdown in the composer's left control cluster (after the Debug button) selects which agent to use; hidden when only one agent is usable
9
9
  - Lists every available agent (from `GET /api/agent-configs`; overridden, disabled and internal agents are hidden), highest priority first. A new session starts on the top one
10
10
  - **Locked during an active session** (and while a response is streaming) — the agent is bound to the session; to change, start a new session (/session new)
11
+ - While it can be changed, the Bot icon is primary-coloured (same "active" colour as the other toolbar toggles); locked, it stays muted grey and dimmed, and hovering it explains that the agent can't change once the conversation has started — start a new session to use another (`chat.agent.locked`)
11
12
  - The Agents panel's "Test" button can also preselect an agent
12
13
 
13
14
  ### Access level
@@ -96,26 +97,45 @@ When `contextEnabled` is on (default), user messages are auto-prepended with:
96
97
  - Drag to chat input
97
98
  - Clipboard paste
98
99
  - File-picker button
100
+ - Screenshot button (below)
99
101
 
100
102
  Images ride along as base64; multimodal supported. Before sending, every attachment is downscaled (long edge ≤ 1568) and re-encoded as JPEG; an SVG is rasterized the same way (long edge 1024, on white), since vision input only takes jpeg / png / gif / webp. An image the browser can't decode (e.g. HEIC / TIFF in Chrome) and that isn't one of those four is refused when attached, with an inline notice naming the file — it is never sent only to be dropped server-side. Pasted images are also persisted to `<workspace>/.halo/assets/web/inbound/web/<date>/` so a `[图片已保存: /abs/path]` marker survives page reload and renders as a click-to-preview chip (shared with the WeChat channel's inbound media flow).
101
103
 
104
+ ### Screenshot (drag-select a region)
105
+ A Scissors button in the toolbar's input group (upload · **screenshot** · screen · camera) takes one still of the screen and lets the user crop it into an image attachment:
106
+ - Click → a frozen frame opens in a full-window crop layer over the admin (dark backdrop, area outside the selection dimmed). Drag a box (dragging again redraws it); **Enter**, double-click or ✓ confirms, **Esc** or ✕ cancels. Confirming with no box (or one under 8 px) attaches the whole frame. Enter here never sends the message.
107
+ - The crop is cut from the full-resolution frame and attached as `screenshot-YYYYMMDD-HHMMSS.png` — from then on it's an ordinary attachment (chip, X to remove, downscaled to JPEG on send like any upload).
108
+ - Grey at rest, primary colour while the picker / crop layer is open; clicking it then cancels.
109
+ - Frame source differs by client:
110
+ - **Desktop client** (mac / win): no picker — grabs the display the Halo window is on (only that one on multi-monitor), Halo itself visible in the shot. Needs macOS **Screen Recording**; without it the permission hint shows in the attachment-notice row.
111
+ - **Browser**: the browser's own `getDisplayMedia` picker every time (a page can't read the screen otherwise), one frame, stream stopped right away. Picking "Entire screen" includes the browser's own "sharing" bar — crop it out. Cancelling the picker does nothing.
112
+ - An older desktop client without the screenshot bridge falls back to the browser path (Halo's own source picker).
113
+ - Independent of live capture: never reads or changes a bound screen share.
114
+ - Shown only when a frame source exists and the selected agent's model accepts images — same gate as live capture (no button on mobile browsers).
115
+
102
116
  ### Inline media chips
103
117
  Any message containing `[图片/视频/语音/文件 已保存: /path]` markers (WeChat + web) or a leading `MEDIA: /path` line (agent-emitted, e.g. from `wechat-send`) renders a compact chip with filename + icon. Clicking opens a full-size preview modal (image/video/audio inline, file → download link). The modal has a Download button (top-right, next to close) for image/video/audio; the media URL carries a per-open cache-buster (`&t=<timestamp>`) so overwritten files (same path, new bytes) always show current content. Paths inside the active workspace or under the OS temp dir (`/tmp/`) are previewable; everything else degrades to a non-clickable chip.
104
118
 
105
119
  Images in chat (the modal's full-size image, a user message's screenshot strip and its zoom, markdown images in replies) never show the browser's broken-image icon: a themed pulsing box while loading, the image once loaded, and an `ImageOff` icon + "Image unavailable" / 「图片无法加载」 box if it fails (`shared/components/safe-image.tsx`).
106
120
 
107
- ### Live capture (desktop only)
108
- Lets the agent *see something live* on demand. Desktop client (Electron) only — the entry points never render in a plain browser. Borrows the meeting-app "share" model: the user binds one source, then the agent requests a frame when it actually needs to look.
121
+ ### Live capture (screen share + camera)
122
+ Lets the agent *see something live* on demand. Works in the desktop client (Electron) **and** a plain browser. Borrows the meeting-app "share" model: the user binds a source, then the agent requests a frame when it actually needs to look.
123
+
124
+ Two source kinds in the chat-input toolbar, **independent** — either one alone, or both at once:
125
+ - **Screen / window share** (MonitorUp button). Desktop: opens a picker grid of screens + app windows; a bound window can be grabbed even while it sits in the background. Browser: the browser's own `getDisplayMedia` picker; the share is kept live while bound (frames drawn from it on demand). No screen button where `getDisplayMedia` is missing (mobile browsers) — the camera button stays.
126
+ - **Camera** (Camera button) — opens a picker with a live preview (even for a single webcam), then binds the chosen device. Hidden entirely on a machine with no camera. In the browser the camera stream is held open while bound (so a request that lands while the page has no focus doesn't re-prompt or stall) and released on unbind.
127
+
128
+ Each button is a toggle: grey when off, primary-coloured icon when on. **Clicking an active button again turns that source off** — no picker; in the browser the share / camera stream stops. To switch screen source or camera device, turn it off and on again. Each bound source also shows as a chip right after the agent selector (icon + name, primary colour, X to unbind just that one). In the browser, the browser's own "Stop sharing" bar unbinds the screen (the camera stays).
109
129
 
110
- Two source kinds in the chat-input toolbar, **mutually exclusive** (only one bound at a time):
111
- - **Screen / window share** (MonitorUp button) — opens a picker grid of screens + app windows. A bound window can be grabbed even while it sits in the background.
112
- - **Camera** (Camera button) — toggles the webcam on. Hidden entirely on a machine with no camera.
130
+ Once bound, a frame is **not** attached to every message. Instead a one-line instruction is injected into the next send telling the model how to ask for a look:
131
+ - Only one source bound — "the user is sharing the «X» window" / "sharing «X» from the browser" / "the user has turned the camera on", and the model outputs a line containing exactly `<<<CAPTURE>>>` when it needs the current view.
132
+ - Both bound — one combined instruction ("the user is sharing «X» and has the camera on"): `<<<CAPTURE:screen>>>` for the screen only, `<<<CAPTURE:camera>>>` for the camera only, bare `<<<CAPTURE>>>` for both.
113
133
 
114
- Once bound, a frame is **not** attached to every message. Instead a one-line instruction is injected into the next send ("the user is sharing the «X» window" / "the user has turned the camera on") telling the model to output a line containing exactly `<<<CAPTURE>>>` when it needs to see the current view. On turn completion the frontend detects the marker in any of the turn's replies, grabs one frame, and sends it back as a **visible image message** — the model sees it on its following turn. So it's a cross-turn round-trip: model asks → frame is sent back → model answers next turn. The returned frame is also shown inline on the user bubble so you can see exactly what was sent.
134
+ On turn completion the frontend collects every capture marker in the turn's replies (union; a bare marker = every bound source; a request for a source that isn't on falls back to every bound one, so a request never goes unanswered), grabs each requested source and sends them back as **one visible image message** (screen first) — the model sees them on its following turn. So it's a cross-turn round-trip: model asks → frame(s) sent back → model answers next turn. The returned frames are shown inline on the user bubble so you can see exactly what was sent; a source that failed contributes a short failure note instead of its image. Markers are hidden from the rendered reply.
115
135
 
116
136
  Constraints:
117
- - Shown only when the selected agent's model accepts image input (capture is pointless on a text-only model); switching to a text-only model auto-unbinds.
118
- - Screen share needs macOS **Screen Recording** permission; the camera prompts for **Camera** permission on first use, with an "Open Settings" path if previously denied.
137
+ - Shown only when the selected agent's model accepts image input (capture is pointless on a text-only model); switching to a text-only model auto-unbinds both.
138
+ - Desktop: screen share needs macOS **Screen Recording** permission; the camera prompts for **Camera** permission on first use, with an "Open Settings" path if previously denied. Browser: the page must be served over HTTPS or from localhost (`getDisplayMedia` / `getUserMedia` are absent elsewhere, so the buttons don't render); a denied camera shows a hint pointing at the address bar's site settings.
119
139
  - Binding is **in-memory only** and shared by all chat tabs — a page reload or restart requires re-selecting.
120
140
 
121
141
  ### The agent's face (`self.html`)