@turmind/halo 1.5.10 → 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 (52) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/{388-d5950f264d91625a.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/halo-city.md +6 -0
  10. package/bundled-docs/design/prompt-system.md +1 -1
  11. package/bundled-docs/design/relay.md +6 -6
  12. package/bundled-docs/design/session.md +12 -11
  13. package/bundled-docs/design/web.md +33 -5
  14. package/bundled-docs/dev/acp-adapter.md +59 -175
  15. package/bundled-docs/dev/api.md +5 -2
  16. package/bundled-docs/dev/deploy.md +1 -1
  17. package/bundled-docs/dev/desktop-packaging.md +14 -0
  18. package/bundled-docs/dev/env.md +2 -2
  19. package/bundled-docs/dev/tools.md +8 -8
  20. package/bundled-docs/guide/README.md +1 -1
  21. package/bundled-docs/guide/channels/README.md +3 -3
  22. package/bundled-docs/guide/channels/acp.md +67 -42
  23. package/bundled-docs/guide/channels/feishu.md +1 -1
  24. package/bundled-docs/guide/channels/slack.md +1 -1
  25. package/bundled-docs/guide/channels/telegram.md +1 -1
  26. package/bundled-docs/guide/channels/web.md +10 -8
  27. package/bundled-docs/guide/channels/wechat.md +1 -1
  28. package/bundled-docs/guide/channels/wecom.md +1 -1
  29. package/bundled-docs/guide/chat.md +0 -1
  30. package/bundled-docs/guide/cli.md +1 -2
  31. package/bundled-docs/guide/getting-started.md +1 -1
  32. package/bundled-docs/guide/skills.md +1 -2
  33. package/bundled-docs/requirements/chat.md +14 -0
  34. package/bundled-docs/requirements/command.md +1 -2
  35. package/bundled-docs/requirements/explorer.md +1 -0
  36. package/bundled-docs/requirements/terminal.md +15 -0
  37. package/dist/index.js +810 -333
  38. package/package.json +1 -1
  39. package/templates/agents/__score__/AGENT.md +1 -1
  40. package/templates/agents/default/agent.yaml +0 -1
  41. package/templates/prompts/all/TOOL_SHELL.windows.md +2 -1
  42. package/templates/skills/skill/SKILL.md +1 -1
  43. package/templates/skills/workspace/stage.py +1 -1
  44. package/admin-out/_next/static/chunks/989-1797287c46139412.js +0 -1
  45. package/admin-out/_next/static/chunks/app/page-44ab337b2f071e4d.js +0 -1
  46. package/admin-out/_next/static/css/d53aa54212002364.css +0 -3
  47. package/templates/skills/acp/SKILL.md +0 -243
  48. package/templates/skills/acp/templates/SKILL.md.tmpl +0 -75
  49. package/templates/skills/acp/templates/ask.py +0 -177
  50. package/templates/skills/acp/templates/config.yaml.tmpl +0 -38
  51. /package/admin-out/_next/static/{A-Rjx2yltZDKaSO1qVv7K → GO2W5JoiC46FIxLjtFAXx}/_buildManifest.js +0 -0
  52. /package/admin-out/_next/static/{A-Rjx2yltZDKaSO1qVv7K → GO2W5JoiC46FIxLjtFAXx}/_ssgManifest.js +0 -0
@@ -63,7 +63,7 @@ File: `packages/server/src/routes/web.ts`
63
63
 
64
64
  All public endpoints require `x-token` header (or `?token=` query param) with a valid account token. No cookie auth needed.
65
65
 
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.
66
+ > 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 (web-demo, halo-city) can call these cross-origin.
67
67
 
68
68
  ### POST `/api/web/chat`
69
69
 
@@ -82,8 +82,9 @@ Send a message and receive streaming response via SSE.
82
82
  // Response: text/event-stream
83
83
  data: {"type":"session","sessionId":"web_abc123_m1xyz"}
84
84
  data: {"type":"thinking","text":"..."}
85
- data: {"type":"tool_call","toolName":"read_file","toolInput":{...}}
86
- data: {"type":"tool_result","toolName":"read_file","result":"..."}
85
+ data: {"type":"tool_call","toolName":"file_read","toolUseId":"toolu_01…","toolInput":{...}}
86
+ data: {"type":"tool_result","toolName":"file_read","toolUseId":"toolu_01…","result":"..."} // result capped at 500 chars
87
+ : keepalive // SSE comment every 15 s, see below
87
88
  data: {"type":"stream","text":"Hello! "}
88
89
  data: {"type":"stream","text":"How can I help?"}
89
90
  data: {"type":"switch","sessionId":"..."} // after /session switch or /session new command
@@ -91,6 +92,12 @@ data: {"type":"complete"}
91
92
  data: {"type":"error","error":"..."}
92
93
  ```
93
94
 
95
+ > **`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.
96
+
97
+ #### SSE keepalive
98
+
99
+ `/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:` (web-demo's both do).
100
+
94
101
  > **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
102
 
96
103
  > **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,7 +107,7 @@ data: {"type":"error","error":"..."}
100
107
  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
108
 
102
109
  - `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).
110
+ - `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
111
  - `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
112
 
106
113
  Browser web-demo doesn't use any of these — it relies on per-token defaults. Three accepted transports per request, lowest-priority first:
@@ -121,6 +128,25 @@ Minting never touches the account's active-session pointer — a side session cr
121
128
 
122
129
  **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
130
 
131
+ ### GET `/api/web/sessions`
132
+
133
+ 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`.
134
+
135
+ ```json
136
+ // GET /api/web/sessions?cursor=<nextCursor> (cursor optional)
137
+ {
138
+ "workspace": "/abs/resolved/path",
139
+ "sessions": [ { "sessionId": "web_abc123_m1xyz_q2", "title": "…", "updatedAt": 1791384914714 } ],
140
+ "nextCursor": 1791384900000 // null on the last page
141
+ }
142
+ ```
143
+
144
+ - `title` is the session title, falling back to its description, else `null`.
145
+ - `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).
146
+ - 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`).
147
+ - **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.
148
+ - `workspace` override via query / `x-workspace` header, full tokens only (403 otherwise, like the mint). Bad token → 401.
149
+
124
150
  ### POST `/api/web/stop`
125
151
 
126
152
  Stop the currently running task. Accepts optional `workspace` / `sessionId` overrides as documented above.
@@ -134,6 +160,8 @@ Stop the currently running task. Accepts optional `workspace` / `sessionId` over
134
160
 
135
161
  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
162
 
163
+ 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.
164
+
137
165
  ```json
138
166
  // Response
139
167
  {
@@ -145,7 +173,7 @@ Get a session's message history. Without overrides, returns the account's active
145
173
 
146
174
  ### GET `/api/web/subscribe`
147
175
 
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.
176
+ 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
177
 
150
178
  ## Halo API — Admin endpoints
151
179
 
@@ -1,6 +1,6 @@
1
1
  # ACP Adapter
2
2
 
3
- A stdio bridge that lets any [Agent Client Protocol](https://github.com/zed-industries/agent-client-protocol) (ACP) client — most importantly Anthropic's Claude Code — drive a halo server as if it were a native ACP agent.
3
+ A stdio bridge that lets any [Agent Client Protocol](https://github.com/zed-industries/agent-client-protocol) (ACP) client — an ACP-capable editor such as Zed or a JetBrains IDE — drive a halo server as if it were a native ACP agent.
4
4
 
5
5
  The adapter is **only a translator**: ACP JSON-RPC over stdin/stdout on one side, halo's existing web channel HTTP + SSE on the other. It does not run agents itself, store any state on disk, or duplicate halo's auth / access-level model. One token in, one workspace out, one halo server upstream.
6
6
 
@@ -10,7 +10,7 @@ Topology that motivated the adapter:
10
10
 
11
11
  ```
12
12
  [Mac] [EC2]
13
- Claude Code halo server (port 9527)
13
+ Zed / JetBrains IDE halo server (port 9527)
14
14
  │ ACP / JSON-RPC over stdio │
15
15
  ▼ │
16
16
  halo acp adapter ────HTTP/SSE────────────────▶│
@@ -20,13 +20,13 @@ halo acp adapter ────HTTP/SSE──────────────
20
20
  live on EC2)
21
21
  ```
22
22
 
23
- Claude Code on a developer's laptop wants to talk to a halo agent running in an EC2 / shared dev box. ACP is the protocol; this adapter is what makes the JSON-RPC stream Claude Code emits look like halo's HTTP + SSE chat to the server. Adapter and Claude Code typically run on the same machine; the halo server sits behind whatever endpoint the developer configures.
23
+ An editor on a developer's laptop wants to talk to a halo agent running in an EC2 / shared dev box. ACP is the protocol; this adapter is what makes the JSON-RPC stream the editor emits look like halo's HTTP + SSE chat to the server. Adapter and editor typically run on the same machine; the halo server sits behind whatever endpoint the developer configures.
24
24
 
25
25
  ## Quick start
26
26
 
27
27
  1. Provision a web-channel token. Admin UI → Channels → Web → Create. Grab the token. **For multi-workspace use, pick `full` access level** — readonly / workspace tokens cannot override the workspace per request. For single-workspace use a readonly / workspace token works too: sessions are minted server-side in the token's own namespace, so the ownership gate never bites.
28
28
 
29
- 2. Launch the adapter from your ACP client. For Claude Code, register it as a custom agent (see Claude Code's docs for `claude-code config agent add`):
29
+ 2. Launch the adapter from your ACP client. For Zed / JetBrains, register it as a custom agent (`agent_servers` in Zed's `settings.json` / `~/.jetbrains/acp.json` — full snippets in [guide/channels/acp.md](../guide/channels/acp.md#wiring-into-zed)):
30
30
 
31
31
  ```sh
32
32
  halo acp \
@@ -39,38 +39,6 @@ Claude Code on a developer's laptop wants to talk to a halo agent running in an
39
39
 
40
40
  3. The adapter writes JSON-RPC frames to stdout (1 message per line) and reads stdin the same way. Stderr is reserved for human-readable diagnostics — do not parse.
41
41
 
42
- ## The `acp` skill — direct asks + halo-to-halo bindings
43
-
44
- The most common use of this adapter isn't a third-party ACP client — it's *another halo agent* calling out over ACP. Halo ships a builtin skill `acp` (slash command `/acp`, full access). Its bundled `templates/ask.py` is a unified ACP client; `--kind` picks the peer:
45
-
46
- - `halo` (default) — spawns `halo acp --host --port --scheme --token --workspace` (this adapter) to reach a **remote halo server** (`--scheme` optional, default `http`; pass `https` for a TLS-fronted server)
47
- - `claude` — spawns `claude-agent-acp` (npm `@agentclientprotocol/claude-agent-acp`): local Claude Code, zero config, just `--cwd`
48
- - `kiro` — spawns `kiro-cli acp --trust-all-tools`: local Kiro, zero config `--cwd`, optional `--agent-id`
49
-
50
- Session reuse works the same for every kind: the first call prints `SESSION: <id>` on stdout, follow-ups pass `--session-id <id>`. For `claude` / `kiro`, `session/load` must carry `cwd` + `mcpServers` exactly like `session/new` (kiro-cli exits silently without them) — ask.py fills these in.
51
-
52
- The question text reaches the peer **verbatim — including the peer's own slash commands**. Verified with kiro: `/model` as the question lists its available models; `/model <full-model-id>` switches its model and saves it as default (full id only — fuzzy names like `claude` are rejected). Use this to drive a peer's built-in command set.
53
-
54
- `/acp` verbs:
55
-
56
- - `kiro <question>` / `claude <question>` — ask the local agent directly; no setup or binding needed
57
- - `add` / `list` / `remove` — manage generated `ask-<label>` binding skills (below)
58
-
59
- ### `ask-*` bindings — the multi-remote-halo path
60
-
61
- Remote halo servers are **not** a direct verb: each remote needs its own host/token/workspace, so `/acp add` walks the user through (label, host, port, scheme, workspace, token), then **stamps out a new skill** named `ask-<label>` containing:
62
-
63
- - `SKILL.md` — slash command `/ask-<label>`, instructions tailored to this remote
64
- - `config.yaml` — declares the binding's params so admin Settings shows a form
65
- - `ask.py` — bundled JSON-RPC ↔ stdio helper (one copy per binding, intentional — keeps `/workspace share` bundles self-contained)
66
- - writes the connection values into `settings.yaml` (workspace or global, user picks)
67
-
68
- After install, the local agent can simply do `shell_exec: python3 .../ask-<label>/ask.py "<question>" --host {{params.host}} ...` and halo's runtime substitutes the configured values. **Multiple bindings coexist** — each gets its own slash command, settings namespace, and Admin Settings page.
69
-
70
- Implementation: `~/.halo/global/skills/acp/`. Templates live under `templates/`. `/acp add` is the **only** supported way to set up a 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.
71
-
72
- To remove a binding: `/acp remove` (deletes the skill directory and points out the leftover `ask-<label>:` block in `settings.yaml`).
73
-
74
42
  ## CLI flags
75
43
 
76
44
  | Flag | Required | Notes |
@@ -108,43 +76,77 @@ Notes:
108
76
  - **Repeatable** — pass `--header` as many times as the gateway needs.
109
77
  - **Colon-safe** — the value is split on the *first* `:` only, so header values that themselves contain colons (`Cookie: a=b:c`) survive intact.
110
78
  - **`x-token` always wins** — `--header` cannot override the adapter's own `x-token` / `content-type`; it only adds headers for the layer in front of halo.
111
- - The generated `ask-<label>` bindings (halo→halo, via `ask.py`) do **not** yet expose `--header`; upstream auth through a binding isn't wired. `halo acp` direct is the path when a gateway is in play.
112
79
 
113
80
  ## ACP method coverage
114
81
 
82
+ ACP protocol v1 (stable schema). The [ACP TCK](https://github.com/agentclientprotocol/acp-tck) v1 suite reports **CONFORMANT** — every mandatory requirement passes; capability requirements for features not advertised below are skipped.
83
+
84
+ **Minimum server: halo 1.5.11.** The adapter relies on server behaviour introduced there: `/api/web/subscribe` answering an idle session with one `complete` (older servers wait for the next turn's, so a prompt whose turn ended during a reconnect backoff — or whose queued drain finished before subscribe attached — hangs until another turn runs or the client cancels), `GET /api/web/sessions` (`session/list`), `toolUseId` on tool frames (older servers fall back to order-based pairing), `/api/web/history?since=` (older servers ignore it and send the whole log), a readonly / workspace token addressing the goal session bound to its own session (older servers 403 the goal-mode re-attach / stop), and the SSE keepalive. `halo acp` is often a newer local CLI talking to a remote server — upgrade the server first.
85
+
115
86
  | Method | Implemented? | Notes |
116
87
  |---------------------|--------------|------------------------------------------------------------------------|
117
- | `initialize` | ✅ | Declares `protocolVersion: 1`, `promptCapabilities: { image, embeddedContext }`, `loadSession: true`, no auth methods. |
88
+ | `initialize` | ✅ | Declares `protocolVersion: 1`, `loadSession: true`, `sessionCapabilities: { list: {} }`, `promptCapabilities: { image: true, audio: false, embeddedContext: true }`, `authMethods: []`, and `agentInfo: { name: 'halo', title: 'Halo', version }` (the adapter package version — `src/version.ts`, pinned to `package.json` by a test because the bundled CLI has no `package.json` to read). |
118
89
  | `authenticate` | ✅ (no-op) | Token already passed via launch flags; ACP-side auth has nothing to do. |
119
- | `session/new` | ✅ | Calls `POST /api/web/sessions`; the server mints `web_<accountId>_<ts>_<rand>` and creates the row immediately. The adapter registers the id locally. |
120
- | `session/load` | ✅ | Verifies the supplied id still exists on the halo server (via `/api/web/history` 404), then registers it locally. The ACP client persists ids itself — the adapter holds no on-disk state. |
121
- | `session/prompt` | ✅ | Forwards text + image content blocks to halo. Resource / embedded-context blocks log a stderr warning and are dropped (see "Reverse fs" below for why). |
122
- | `session/cancel` | ✅ | Aborts the in-flight HTTP/SSE stream and POSTs `/web/stop` server-side. |
90
+ | `session/new` | ✅ | Calls `POST /api/web/sessions`; the server mints `web_<accountId>_<ts>_<rand>` and creates the row immediately. The adapter registers the id locally. `cwd` / `mcpServers` are accepted and ignored (the agent runs in the server workspace). |
91
+ | `session/load` | ✅ | Fetches `/api/web/history?sessionId=<id>` (404 → `-32602`), replays the session's **active log** as `session/update`s — all of them before the response — then answers `{}` and registers the id locally. That is the whole conversation until the session file is archived; after archiving (a compact on a log over the size threshold) only the newest part remains in the active log, so only that part is replayed. See "History replay" below. A turn still running server-side is replayed as far as it got; the live tail isn't attached. |
92
+ | `session/list` | ✅ | `GET /api/web/sessions` — the token's own root sessions, newest first, 50 per page. Each entry is `{ sessionId, cwd, title?, updatedAt }`: `cwd` is the server-resolved workspace, `title` falls back to the session description (omitted when neither is set), `updatedAt` is ISO 8601. A `cwd` filter that isn't that workspace → `{ sessions: [] }`. `cursor` is opaque (the server's `nextCursor`, an updatedAt epoch-ms, as a string); anything that isn't plain digits → `-32602`, an empty one is the first page. |
93
+ | `session/prompt` | ✅ | Content blocks → one halo message + `images[]` (`composePrompt` in `src/adapter.ts`), in prompt order — consecutive `text` blocks concatenate, every other block becomes its own paragraph: `image` → `images[]`; `resource` with `text` → a `[resource: <uri>]` line + the content in a fenced block (fence longer than any backtick run inside); `resource` with `blob` → `[binary resource omitted: <uri>]`; `resource_link` → `[resource link: <name> <uri>]` — a pointer only, the server can't open a client-local path. Text and `resource_link` are the v1 baseline every agent MUST accept; `image` / `resource` are the advertised `image` / `embeddedContext` capabilities. Anything else (`audio` — not advertised — or an unknown type) is dropped with a stderr warning. A second prompt on a session whose turn is still in flight → `-32600`; an empty prompt → `-32602`. Resolves `end_turn` or `cancelled` — see "Prompt turn lifecycle" below. |
94
+ | `session/cancel` | ✅ | A notification (the legacy request form is still answered `null`). Fires `/web/stop` and keeps reading the stream so the stop's own updates reach the client before the prompt resolves `cancelled` — see "Prompt turn lifecycle". |
95
+ | Unknown methods | — | Requests → `-32601` (method not found); notifications — including `$/cancel_request` — are ignored and the connection stays usable. |
96
+ | `session/resume`, `session/set_mode`, `session/set_config_option`, `session/delete`, `session/close`, `logout` | ❌ | Not advertised, so they answer `-32601`. |
123
97
  | Reverse `fs/*` | ❌ | See "Reverse fs" below. |
124
98
  | Reverse `terminal/*`| ❌ | Same reasoning as reverse fs. |
125
99
  | `requestPermission` | ❌ | Halo has its own access-level system at the channel-account level; we don't surface a second permission gate at ACP. |
126
100
 
101
+ Also not sent: `available_commands_update`, `plan`, tool `locations` (paths are server-side — the client couldn't open them). The client's `mcpServers` are not connected.
102
+
127
103
  ### Session id model
128
104
 
129
- ACP sessionId == halo sessionId. There's no extra mapping layer in the adapter: `session/new` asks the server for a fresh session (`POST /api/web/sessions`), which mints `web_<accountId>_<ts>_<rand>` inside the token's own namespace and creates the `agent_sessions` row on the spot — that exact string is what the ACP client gets back. The server has to be the one minting because readonly / workspace tokens can only address ids under their own `web_<accountId>_` prefix (`canAddressSession` in `packages/server/src/channels/web/handler.ts`); an adapter-chosen id would 403 on the first prompt. When the ACP client persists the id and replays it via `session/load`, the adapter just calls `/api/web/history?sessionId=<id>` to verify the row still exists, then registers it in its local in-memory map for prompt / cancel routing.
105
+ ACP sessionId == halo sessionId. There's no extra mapping layer in the adapter: `session/new` asks the server for a fresh session (`POST /api/web/sessions`), which mints `web_<accountId>_<ts>_<rand>` inside the token's own namespace and creates the `agent_sessions` row on the spot — that exact string is what the ACP client gets back. The server has to be the one minting because readonly / workspace tokens can only address ids under their own `web_<accountId>_` prefix (`canAddressSession` in `packages/server/src/channels/web/handler.ts`); an adapter-chosen id would 403 on the first prompt. When the ACP client persists the id and replays it via `session/load`, the adapter fetches its history from `/api/web/history?sessionId=<id>` (which doubles as the existence check), replays it, then registers the id in its local in-memory map for prompt / cancel routing.
130
106
 
131
- This keeps the adapter stateless on disk — losing the in-memory map on restart is harmless because the conversation lives on the halo server. **The ACP client is the source of truth for "which sessions are mine"**, which is the right shape: a Mac-side Claude Code knows about *its* sessions, the EC2-side halo agent doesn't need to enumerate them.
107
+ This keeps the adapter stateless on disk — losing the in-memory map on restart is harmless because the conversation lives on the halo server, which is also where `session/list` reads from: the server enumerates the token's own `web_<accountId>_*` root sessions (sub-agent sessions and archived ones excluded). The list is prefix-scoped even for a `full` token — it answers "this token's conversations", not "everything in the workspace"; a full token can still `session/load` any id it already knows. Sessions minted by a browser client on the same token show up too (see the namespace-sharing note in [design/web.md](../design/web.md#post-apiwebsessions)).
108
+
109
+ Paging caveat: the cursor means "updated strictly before this millisecond", so two sessions with the same `updatedAt` straddling a page boundary can lose the second one. This is the shared session-list query's behaviour.
110
+
111
+ ### Prompt turn lifecycle
112
+
113
+ - **Normal turn** — `POST /api/web/chat` streams until the terminal `complete` → `end_turn`.
114
+ - **Busy session** — halo answers `queued` (the message drains inside the run already in flight). The adapter follows that run via `/api/web/subscribe` to its terminal `complete`, then settles against history (below) so the drained reply is never lost → `end_turn`.
115
+ - **Dropped stream** (proxy idle timeout, network blip, server restart — the stream ended without `complete` and without a cancel) — re-attach via `/api/web/subscribe` with backoff 1 / 2 / 4 / 8 / 16 s. A re-attach that delivers frames resets the budget, so a long task survives repeated drops. Once a re-attached stream completes (or subscribe reports the session idle) the reply is **settled against history**: the reply logged for this prompt is compared with what was streamed — already complete → nothing; streamed text is a prefix → only the missing tail is sent; anything else (a gap mid-reply) → the whole reply again behind a `[reconnected — full reply]` marker, so the client never silently shows a reply with a hole in it. `MEDIA:` marker lines are stripped from the logged reply one line at a time, exactly as the server strips them from the stream, so a send-file reply doesn't trip the marker. The settle fetches only this turn's tail (`/api/web/history?since=<turn start − 5 min skew margin>`), not the whole log. After 5 failed attempts in a row: `[adapter error] connection lost`, `end_turn`. The re-attach and the settle follow the session id from the stream's `session` frame — a goal-bound session routes to `goal_<ts>`, which the server lets the token address because it is bound to the token's own session.
116
+ - **Chat request fails before its first event** (HTTP error, server unreachable) — `[adapter error] <message>`, `end_turn`; no reconnect (nothing to re-attach to).
117
+ - **Cancel** — `session/cancel` aborts the turn, fires `POST /api/web/stop` (not awaited) for the session actually running it (the id latched from the `session` frame — the goal session when routed, not the ACP id), and keeps reading the stream until halo's `complete`, so the interrupted tool rows and final text arrive **before** the `cancelled` response (ACP prompt-turn "Cancellation"). If no `complete` arrives within 5 s the HTTP stream is aborted and the prompt resolves `cancelled` anyway. A cancel during a reconnect backoff, or while the reply is being settled against history, resolves `cancelled` without waiting out the delay. Until the cancelled prompt resolves the session's slot is still held, so a new `session/prompt` sent inside that window (≤ 5 s) gets `-32600` — send it after the `cancelled` response.
118
+ - The server's 15 s SSE keepalive comment (`: keepalive`, see [design/web.md](../design/web.md#sse-keepalive)) keeps idle proxies from cutting a long tool call; the adapter's parser skips it.
132
119
 
133
120
  ## Halo SSE → ACP `session/update` mapping
134
121
 
135
122
  | Halo event | ACP notification | Notes |
136
123
  |----------------|----------------------------------|--------------------------------------------|
137
- | `session` | (latched internally) | First-frame echo of the resolved sessionId. Adapter records it; not surfaced. |
124
+ | `session` | (latched internally) | First-frame echo of the resolved sessionId. Adapter records it as the session to re-attach to, settle against and stop (a goal-bound session routes elsewhere); not surfaced. |
138
125
  | `stream` (assistant text) | `agent_message_chunk` | Forwarded as `content: { type: 'text', text }`. |
139
126
  | `thinking` | `agent_thought_chunk` | Same shape as message chunk. |
140
- | `tool_call` | `tool_call` (status: in_progress) | Adapter mints a stable `toolCallId`; the halo `toolInput` goes out as `rawInput`. `kind: 'other'` because halo doesn't categorize tools. |
141
- | `tool_result` | `tool_call_update` (status: completed) | Pairs by *order* with the most recent `tool_call` (the frame's `toolName`, when present, must match it). A result with no matching call is sent as a self-contained `tool_call` + `tool_call_update`. The web channel truncates `result` to 500 chars. |
127
+ | `tool_call` | `tool_call` (status: in_progress) | `toolCallId` = the frame's `toolUseId` (the provider's tool_use id — stable across a re-attach and identical to what `session/load` replays); a server without the field, or an empty id (some OpenAI-compatible streams), gets a minted id. `title` = tool name + the first 80 chars of its `command` / `pattern` / `url` / `path` argument. `kind`: `file_read` → `read`; `file_write` / `file_edit` → `edit`; `shell_exec` → `execute`; `grep` / `glob` / `file_list` → `search`; `web_fetch` → `fetch`; anything else → `other`. The halo `toolInput` goes out as `rawInput`. No `locations`. |
128
+ | `tool_result` | `tool_call_update` (status: completed) | Pairs by `toolUseId`, so interleaved calls complete correctly. Without an id it falls back to the most recent `tool_call` (whose `toolName`, when the frame has one, must match). A result with no matching call is sent as a self-contained `tool_call` + `tool_call_update`. The web channel truncates `result` to 500 chars. |
142
129
  | `file` | `agent_message_chunk: [file: …]` | The file lives on the server; without reverse fs we can only point at it textually. |
143
130
  | `error` | `agent_message_chunk: [error] …` then end | Ends the prompt response with `stopReason: 'end_turn'` (we treat agent errors as a normal end-of-turn for protocol purposes). |
144
- | `queued` | `agent_message_chunk: [queued — session busy]` | Halo queues messages when the session is busy. Adapter ends the response. |
145
- | `complete` | (none — resolves the prompt) | Caller's `session/prompt` request resolves with `stopReason: 'end_turn'`. |
131
+ | `queued` | (none — follow via subscribe) | Halo queued the message because the session is busy; the adapter follows the running session to its terminal `complete` (see "Prompt turn lifecycle"). |
132
+ | `complete` | (none — resolves the prompt) | Caller's `session/prompt` request resolves with `stopReason: 'end_turn'` (`cancelled` after a cancel). |
146
133
  | `user` | (dropped) | Halo echoes the prompt; surfacing it would just confuse the ACP client. |
147
134
  | `switch` | (dropped) | Internal slash-command bookkeeping; ACP adapter doesn't send slash commands. |
135
+ | `: keepalive` comment | (skipped) | Not a `data:` frame — the SSE parser ignores it. |
136
+ | anything else | (dropped) | Forward compat with newer servers. |
137
+
138
+ ### History replay (`session/load`)
139
+
140
+ Built from `/api/web/history` messages in log order (`src/acp-updates.ts` `replayUpdates`), using the same builders as the live stream so a tool call looks the same either way:
141
+
142
+ | History entry | Replayed as |
143
+ |---|---|
144
+ | user message | `user_message_chunk` |
145
+ | assistant `contentBlocks`, in order | `agent_thought_chunk` (thinking) / `tool_call` / `agent_message_chunk` (text) |
146
+ | assistant without `contentBlocks` (legacy layout) | its `toolCalls` first, then the text — the admin's rendering order for these files |
147
+ | deleted turns, sub-agent `taskId` rows, system rows (usage / context / notification / agent_start / agent_done), standalone `tool_call` / `tool_result` rows | skipped |
148
+
149
+ Each replayed `tool_call` is self-contained: `status: completed`, `toolCallId` = its `toolUseId` (fallback `replay-<msgId>-<j>`), `title` / `kind` as above, `rawInput`, plus `rawOutput` + `content` capped at 500 chars like the live frame. The standalone `tool_call` / `tool_result` log rows are skipped because they carry neither the output nor the `toolUseId` — the assistant message's blocks have both.
148
150
 
149
151
  ## Multi-workspace
150
152
 
@@ -165,22 +167,24 @@ We don't implement this in v1. Two reasons:
165
167
  1. The web channel is HTTP + SSE — a one-way stream. Reverse fs needs client-initiated requests in the agent → client direction. We'd have to either swap the wire to WebSocket or layer long-poll on top.
166
168
  2. Halo agents currently use the `file_read` / `file_write` tools that operate on the *server's* workspace directly. To benefit from reverse fs we'd need a parallel tool (`client_file_read`?) and a way for the agent to know when to use which.
167
169
 
168
- For now: if the user wants the agent to see a Mac-side file, they 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.
170
+ What works instead: a file the client attaches as an embedded `resource` (e.g. an editor @-mention) arrives inline in the prompt text, so the agent can read it — it just can't fetch or write back client-side files on its own. A `resource_link` reaches the agent only as a name + uri line.
169
171
 
170
172
  ## Implementation notes
171
173
 
172
174
  Code is in `packages/acp-adapter/`:
173
175
 
174
176
  - `src/jsonrpc.ts` — minimal newline-delimited JSON-RPC 2.0 peer over stdio. No LSP-style Content-Length framing — ACP uses one JSON object per line.
175
- - `src/halo-client.ts` — wraps `POST /api/web/sessions`, `POST /api/web/chat` (SSE), `POST /api/web/stop` and `GET /api/web/history`. Parses `data: <json>\n\n` frames into JS objects.
176
- - `src/adapter.ts` — registers the ACP method handlers, owns the per-session state (`Map<sessionId, { workspace, lastToolCall, promptAbort }>` — `sessionId` is shared with halo), translates SSE events to `session/update` notifications.
177
+ - `src/halo-client.ts` — wraps `POST|GET /api/web/sessions`, `POST /api/web/chat` (SSE), `GET /api/web/subscribe` (SSE), `POST /api/web/stop` and `GET /api/web/history`. Parses `data: <json>\n\n` frames into JS objects; non-`data:` lines (the keepalive comment) are skipped.
178
+ - `src/adapter.ts` — registers the ACP method handlers, owns the per-session state (`Map<sessionId, { workspace, turn? }>` — `sessionId` is shared with halo; `turn` holds the in-flight prompt's cancel / HTTP abort controllers, the latched halo session id, streamed text and tool-call pairing), runs the prompt turn lifecycle, translates SSE events to `session/update` notifications. The halo client is injectable (`AdapterDeps`) so `test/adapter.test.ts` drives it against a scripted fake.
179
+ - `src/acp-updates.ts` — pure halo → ACP builders shared by the live stream and `session/load` replay: `toolKind` / `toolTitle`, `replayUpdates`, `replyAfterPrompt` (the reply settled against after a reconnect).
180
+ - `src/version.ts` — `ADAPTER_VERSION` for `agentInfo`.
177
181
  - `src/index.ts` — CLI argv parsing, wires stdin/stdout to a `JsonRpcConnection`, instantiates the adapter.
178
182
 
179
183
  CLI integration is in `@turmind/halo-cli`'s `index.ts` `cmd === 'acp'` branch — it imports `@turmind/halo-acp-adapter` and forwards argv. The adapter does not gate on `~/.halo/global/` being initialized: it only talks to a remote server.
180
184
 
181
185
  ## Testing
182
186
 
183
- `packages/acp-adapter/test/` holds vitest unit tests for the JSON-RPC framing and SSE parsing (`pnpm --filter @turmind/halo-acp-adapter test`); the adapter ↔ server flow has no automated suite and is verified by manual smoke. The cases below cover the protocol surface and the realistic end-to-end shape (Claude Code → ACP adapter → halo server → remote agent). When you change adapter / web-channel / settings code, walk this list.
187
+ `packages/acp-adapter/test/` holds the vitest suite (`pnpm --filter @turmind/halo-acp-adapter test`): JSON-RPC framing, SSE parsing, the version pin, and `adapter.test.ts` — contract tests that drive the adapter over in-memory stdio against a scripted fake halo client (initialize, cancel, load replay, list, prompt content blocks, tool-call pairing, reconnect / settle, busy session, unknown methods). A live server and model are only exercised by the manual smoke below. The cases below cover the protocol surface and the realistic end-to-end shape (ACP client → adapter → halo server → agent). When you change adapter / web-channel code, walk this list.
184
188
 
185
189
  ### Setup
186
190
 
@@ -200,8 +204,6 @@ WS=/home/ubuntu/sa-agent
200
204
 
201
205
  ### Layer 1 — adapter alone (raw stdio)
202
206
 
203
- Use these to bisect: if the adapter works here but the skill flow fails, the bug is in the skill / shell_exec substitution, not the adapter.
204
-
205
207
  **1.1 initialize handshake (1 line in, 1 response out, exits cleanly)**
206
208
 
207
209
  ```sh
@@ -238,127 +240,9 @@ Two `session/new` from the same adapter, two `session/prompt` fired with `Promis
238
240
 
239
241
  Long prompt (`"count to 50 with commentary"`), `setTimeout(() => send('session/cancel',{sessionId}), 1500)`, expect the original prompt resolves with `stopReason: 'cancelled'`.
240
242
 
241
- ### Layer 2 — generated binding skill (helper script + settings)
242
-
243
- Each `/acp add` run produces a binding under `<scope>/skills/ask-<label>/`. These tests assume one binding `ask-sa-agent` already exists with the workspace-scope settings populated. Use a different `<label>` if you've changed the example.
244
-
245
- **2.1 minimal helper invocation**
246
-
247
- ```sh
248
- cd /home/ubuntu/halo-test
249
- python3 .halo/skills/ask-sa-agent/ask.py \
250
- "本月 EC2 总花费一句话告诉我" \
251
- --host localhost --port 9527 --token "$TOKEN" --workspace "$WS"
252
- ```
253
-
254
- Expect stdout:
255
-
256
- ```
257
- SESSION: web_<accountId>_<ts>_<rand>
258
- ---
259
- 本月(2026-05…)EC2 总花费 约 $1,846 …
260
- ```
261
-
262
- **2.2 helper with `--agent-id ""` (empty literal — should be ignored)**
263
-
264
- ```sh
265
- python3 .halo/skills/ask-sa-agent/ask.py "ping" \
266
- --host localhost --port 9527 --token "$TOKEN" --workspace "$WS" --agent-id ""
267
- ```
268
-
269
- Expect: works the same as 2.1 (uses remote `default` agent). Regression guard for the bug where an unset yaml `agent_id: ""` got passed through and crashed remote `createSession`.
270
-
271
- **2.3 helper with `--agent-id '{{params.agent_id}}'` (unsubstituted literal)**
272
-
273
- ```sh
274
- python3 .halo/skills/ask-sa-agent/ask.py "ping" \
275
- --host localhost --port 9527 --token "$TOKEN" --workspace "$WS" \
276
- --agent-id "{{ask-sa-agent.params.agent_id}}"
277
- ```
278
-
279
- Expect: same as 2.1. Regression guard for the case where `settings.yaml` doesn't have `agent_id` at all — `ask.py` detects the `{{…}}` shape and drops the flag.
280
-
281
- ### Layer 3 — agent calls the binding via halo cli
282
-
283
- Tests the full chain: agent → `activate_skill` → `shell_exec` → ask.py → adapter → server → remote agent → reply.
284
-
285
- **3.1 explicit slash command**
286
-
287
- ```sh
288
- cd /home/ubuntu/halo-test
289
- halo cli -a default -n -w /home/ubuntu/halo-test \
290
- "/ask-sa-agent 一句话告诉我这个月 EC2 总花费"
291
- ```
292
-
293
- Expect: agent activates the binding, runs `ask.py` once with all params populated from settings.yaml, replies with "我问了 SA Agent…本月 EC2 总花费 约 $1,846…".
294
-
295
- **3.2 implicit binding selection (agent picks on its own)**
296
-
297
- ```sh
298
- halo cli -a default -n -w /home/ubuntu/halo-test \
299
- "帮我问下 sa-agent 这个月 RDS 花了多少钱"
300
- ```
301
-
302
- Expect: agent recognises "ask sa-agent" as a delegation cue, picks `ask-sa-agent` without being told. (Softer test — model behaviour, not protocol; failure usually means the binding's SKILL.md description needs sharpening for the model.)
303
-
304
- **3.3 multi-turn with session resume**
305
-
306
- In the same CLI session, send three messages in order:
307
-
308
- ```
309
- 让 sa-agent 拉一下本月 AWS top 10 service 费用
310
- 让它把 Bedrock 那部分按模型拆细
311
- 再问 us-east-1 跟 ap-northeast-1 的分布
312
- ```
313
-
314
- Expect: agent saves the `SESSION:` id from message 1, passes `--session-id <id>` on messages 2/3. Remote sa-agent's responses build on the previous turn (no "what AWS account?" re-prompts).
315
-
316
- ### Layer 4 — generator (`/acp add`)
317
-
318
- Tests that a fresh binding can be stamped out from scratch.
319
-
320
- **4.1 generate a binding**
321
-
322
- From a clean state (no `<workspace>/.halo/skills/ask-foo/`):
323
-
324
- ```sh
325
- halo cli -a default -n -w /home/ubuntu/halo-test \
326
- '/acp add 参数:label=foo,host=localhost,port=9527,workspace=/home/ubuntu/sa-agent,token=<token>,scope=workspace。不要问后续问题,全自动创建。'
327
- ```
328
-
329
- Expect: agent creates `.halo/skills/ask-foo/{SKILL.md,config.yaml,ask.py}`, writes `ask-foo` block to `<workspace>/.halo/settings.yaml` with **all 5 user values** (host/port/workspace/label/token) plus `scheme: http` (defaulted — the prompt omits it), wires the binding into the current agent's skills list. Reply confirms the four paths.
330
-
331
- **4.2 invoke the freshly-generated binding**
332
-
333
- ```sh
334
- halo cli -a default -n -w /home/ubuntu/halo-test \
335
- '/ask-foo 给我一句话: 这个月 EC2 总花费'
336
- ```
337
-
338
- Expect: real reply (not a 401 / "token not configured"). If 401, settings.yaml didn't get all 5 values written (regression on Step 4 of the meta-skill).
339
-
340
- **4.3 cleanup**
341
-
342
- ```sh
343
- rm -rf /home/ubuntu/halo-test/.halo/skills/ask-foo
344
- # manually remove `ask-foo:` block from /home/ubuntu/halo-test/.halo/settings.yaml
345
- ```
346
-
347
- ### Layer 5 — admin Settings UI
348
-
349
- Open admin → Settings → Skills → **Ask SA Agent** (or whichever binding):
350
-
351
- **5.1** All 7 fields render: `host`, `port`, `scheme`, `workspace`, `label`, `agent_id`, `token` (with mask icon).
352
-
353
- **5.2** Each field's source label says **workspace** (not "继承自 global"), because values live in `<halo-test>/.halo/settings.yaml`.
354
-
355
- **5.3** Edit `label` (e.g. → "我的 SA 助手"), save, run a 3.1-style query — agent's reply preamble should use the new label.
356
-
357
243
  ### Bisection guide
358
244
 
359
245
  When something fails:
360
246
 
361
- 1. **3.x fails, 2.x works**: bug is in the agent's command construction (SKILL.md prompts) or shell_exec substitution. Look at the actual `tool_call.input` in the session JSON — placeholder text `{{…}}` leaking into the cmd is the typical sign.
362
- 2. **2.x fails, 1.x works**: bug is in `ask.py` (helper) or how params reach it.
363
- 3. **1.x fails, raw curl to `/api/web/chat` works**: bug is in `halo acp` adapter (jsonrpc.ts / adapter.ts / halo-client.ts).
364
- 4. **raw curl fails too**: bug is in halo server (web/handler.ts / session-manager) or the remote workspace itself (model creds, agent.yaml, …).
247
+ 1. **1.x fails, raw curl to `/api/web/chat` works**: bug is in `halo acp` adapter (jsonrpc.ts / adapter.ts / halo-client.ts).
248
+ 2. **raw curl fails too**: bug is in halo server (web/handler.ts / session-manager) or the remote workspace itself (model creds, agent.yaml, …).
@@ -305,11 +305,14 @@ gets JSON, and additionally gates on `accessLevel`.
305
305
  |---|---|---|
306
306
  | POST | `/api/web/chat` | Send message, receive SSE stream. Body: `{message, images?, workspace?, sessionId?, agentId?}` (overrides also accepted as `?workspace=`/`?sessionId=` query or `x-workspace`/`x-session-id` headers; `workspace` only honored when token has `accessLevel: full`). `sessionId` must be a safe id segment (else 400) and owned by the token unless `accessLevel: full` → otherwise 403. A client that disconnects ends its stream right away; the turn keeps running (reconnect via `/subscribe`, stop via `/stop`). |
307
307
  | POST | `/api/web/sessions` | Mint a root session in the token's own `web_<accountId>_` namespace → `{sessionId}`. Body / query / header: `workspace?` (full tokens only), `agentId?`. Used by the ACP adapter's `session/new`. |
308
+ | GET | `/api/web/sessions?cursor=` | One page (50) of the token's own root sessions (`web_<accountId>_*`, non-archived), newest first → `{workspace, sessions: [{sessionId, title, updatedAt}], nextCursor}`. `title` falls back to the description, else `null`; `nextCursor` is the last row's `updatedAt` (null = last page) — pass it back as `cursor`. `cursor` must be digits only (else 400); empty = first page. A `workspace` with no `.halo/` → empty page, nothing scaffolded. Prefix-scoped for every access level, `full` included; `workspace?` override (query / header) full tokens only → else 403. Used by the ACP adapter's `session/list`. |
308
309
  | POST | `/api/web/stop` | Stop running task → `{stopped: boolean}` |
309
- | GET | `/api/web/history` | Active session history → `{sessionId, messages[], running}`. With an explicit `sessionId` that doesn't exist in the workspace → 404 `{error: "session not found"}` |
310
- | GET | `/api/web/subscribe` | Reconnect SSE to running session |
310
+ | GET | `/api/web/history?since=` | Active session history → `{sessionId, messages[], running}`. With an explicit `sessionId` that doesn't exist in the workspace → 404 `{error: "session not found"}`. Optional `since` (epoch ms, digits only, else 400): only root-log rows (no `taskId`) with `timestamp >= since`, filtered before serialization — the ACP adapter's reconnect settle passes its turn start instead of fetching the whole log |
311
+ | GET | `/api/web/subscribe` | Reconnect SSE to running session. Opens with a `session` frame; an idle (not running, not compacting) session gets a single `complete` and the stream closes. |
311
312
  | GET | `/api/web/file?path=` | Inline-serve a workspace-relative file (image / video / pdf etc.). Path-traversal-checked against the token's bound workspace: the lexical check is re-verified against the realpath'd root, so a symlink inside the workspace pointing outside it returns 403; a dangling symlink (target doesn't exist) returns 404. Workspace runtime state (`.halo/sessions`, `.halo/logs`, `.halo/evo`, `halo.db*`) is hidden for every access level → 403 `{error: "path is not accessible"}`, checked on the realpath so symlinks into those dirs are caught too. |
312
313
 
314
+ SSE responses (`/api/web/chat`, `/api/web/subscribe`) carry an SSE comment line `: keepalive` every 15 s so idle proxies don't cut a stream during a long tool call; it isn't an event — parsers must skip non-`data:` lines. `tool_call` / `tool_result` frames carry `toolUseId` (may be empty — treat as missing) for pairing a result with its call.
315
+
313
316
  See [design/web.md](../design/web.md).
314
317
 
315
318
  ## Show (world snapshot)
@@ -219,7 +219,7 @@ This installs the `halo` binary on `$PATH`. Subcommands available:
219
219
  | `halo tui` | Interactive TUI client |
220
220
  | `halo cli "<prompt>"` | One-shot prompt → reply, exit |
221
221
  | `halo agents` / `halo sessions` | List agents / sessions |
222
- | `halo acp` | Stdio bridge that lets an ACP client (e.g. Claude Code) drive a halo server — see [acp-adapter.md](acp-adapter.md) |
222
+ | `halo acp` | Stdio bridge that lets an ACP client (e.g. Zed / JetBrains) drive a halo server — see [acp-adapter.md](acp-adapter.md) |
223
223
 
224
224
  ### Upgrade flow
225
225
 
@@ -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) |