@turmind/halo 1.5.10 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/{388-d5950f264d91625a.js → 147-d4200c70445aabb6.js} +1 -1
- package/admin-out/_next/static/chunks/989-a7f752480eaaa2dc.js +1 -0
- package/admin-out/_next/static/chunks/app/{layout-5ec6c5f713a8562b.js → layout-ea161287d934ba96.js} +1 -1
- package/admin-out/_next/static/chunks/app/page-8173112f256bc483.js +1 -0
- package/admin-out/_next/static/chunks/{webpack-efd27999305b080a.js → webpack-dc73047e0279979b.js} +1 -1
- package/admin-out/_next/static/css/7b736202f0b1dbfd.css +1 -0
- package/admin-out/_next/static/css/981c9e5610ce535a.css +1 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +6 -5
- package/bundled-docs/design/a2a.md +286 -0
- package/bundled-docs/design/agent.md +7 -5
- package/bundled-docs/design/agentcore.md +194 -122
- package/bundled-docs/design/architecture.md +2 -2
- package/bundled-docs/design/canvas-extensions.md +30 -9
- package/bundled-docs/design/halo-city.md +6 -0
- package/bundled-docs/design/observability.md +12 -4
- package/bundled-docs/design/prompt-system.md +1 -1
- package/bundled-docs/design/relay.md +6 -6
- package/bundled-docs/design/session.md +15 -12
- package/bundled-docs/design/storage.md +9 -2
- package/bundled-docs/design/web.md +36 -59
- package/bundled-docs/dev/acp-adapter.md +59 -175
- package/bundled-docs/dev/api.md +75 -6
- package/bundled-docs/dev/deploy.md +3 -2
- package/bundled-docs/dev/desktop-packaging.md +14 -0
- package/bundled-docs/dev/env.md +10 -6
- package/bundled-docs/dev/tools.md +101 -11
- package/bundled-docs/guide/README.md +1 -1
- package/bundled-docs/guide/channels/README.md +3 -3
- package/bundled-docs/guide/channels/acp.md +67 -42
- package/bundled-docs/guide/channels/feishu.md +1 -1
- package/bundled-docs/guide/channels/slack.md +1 -1
- package/bundled-docs/guide/channels/telegram.md +1 -1
- package/bundled-docs/guide/channels/web.md +10 -34
- package/bundled-docs/guide/channels/wechat.md +1 -1
- package/bundled-docs/guide/channels/wecom.md +1 -1
- package/bundled-docs/guide/chat.md +0 -1
- package/bundled-docs/guide/cli.md +41 -6
- package/bundled-docs/guide/delegation-and-access.md +3 -2
- package/bundled-docs/guide/getting-started.md +1 -1
- package/bundled-docs/guide/skills.md +2 -3
- package/bundled-docs/requirements/agent.md +1 -1
- package/bundled-docs/requirements/chat.md +17 -1
- package/bundled-docs/requirements/command.md +1 -2
- package/bundled-docs/requirements/editor.md +9 -3
- package/bundled-docs/requirements/explorer.md +1 -0
- package/bundled-docs/requirements/terminal.md +15 -0
- package/bundled-docs/requirements/workspace.md +6 -0
- package/dist/index.js +5392 -2786
- package/package.json +1 -1
- package/templates/agents/__score__/AGENT.md +1 -1
- package/templates/agents/default/agent.yaml +0 -1
- package/templates/models/aws-bedrock-claude-invoke.yaml +40 -1
- package/templates/models/aws-bedrock-openai.yaml +100 -6
- package/templates/prompts/all/RUNTIME.md +2 -1
- package/templates/prompts/all/TOOL_SHELL.windows.md +2 -1
- package/templates/skills/send-file/SKILL.md +1 -1
- package/templates/skills/send-file/a2a.md +13 -0
- package/templates/skills/skill/SKILL.md +1 -1
- package/templates/skills/workspace/stage.py +1 -1
- package/admin-out/_next/static/chunks/989-1797287c46139412.js +0 -1
- package/admin-out/_next/static/chunks/app/page-44ab337b2f071e4d.js +0 -1
- package/admin-out/_next/static/css/d53aa54212002364.css +0 -3
- package/templates/skills/acp/SKILL.md +0 -243
- package/templates/skills/acp/templates/SKILL.md.tmpl +0 -75
- package/templates/skills/acp/templates/ask.py +0 -177
- package/templates/skills/acp/templates/config.yaml.tmpl +0 -38
- /package/admin-out/_next/static/{A-Rjx2yltZDKaSO1qVv7K → _F_Fkyh9PainUNY82EEjY}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{A-Rjx2yltZDKaSO1qVv7K → _F_Fkyh9PainUNY82EEjY}/_ssgManifest.js +0 -0
|
@@ -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 —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 }`, `
|
|
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` | ✅ |
|
|
121
|
-
| `session/
|
|
122
|
-
| `session/
|
|
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
|
|
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
|
|
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) |
|
|
141
|
-
| `tool_result` | `tool_call_update` (status: completed) | Pairs by
|
|
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` |
|
|
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
|
-
|
|
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,
|
|
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
|
|
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. **
|
|
362
|
-
2. **
|
|
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, …).
|
package/bundled-docs/dev/api.md
CHANGED
|
@@ -54,10 +54,12 @@ All file operations validate the path stays inside the project root (prevents pa
|
|
|
54
54
|
|---|---|---|
|
|
55
55
|
| GET | `/api/fs/home` | Returns server `homedir()` — fallback when the frontend has no `?folder=` URL param. Returns `{ home }`. |
|
|
56
56
|
| GET | `/api/fs/exists?path=/abs` | Validates an absolute path exists and whether it's a directory. Returns `{exists, isDirectory?}`. |
|
|
57
|
-
| GET | `/api/fs/browse?path=/abs` | Lists immediate directory children (hidden ones dropped). Returns `{path, parent, entries: [{name, path}]}`. |
|
|
57
|
+
| GET | `/api/fs/browse?path=/abs` | Lists immediate directory children (hidden ones dropped). Returns `{path, parent, entries: [{name, path}]}`. With `files=1` (the extension file picker, capability `fs-read`): files too — `entries: [{name, path, type: 'file'\|'directory', size?}]`, directories first, dot-entries shown, only the file tree's noise (`.git`, `.DS_Store`, `node_modules`, `__pycache__`) skipped, symlinks counted as what they point at; `sizes=1` adds each file's `size`. A directory on the sandbox hidden lists answers 404 like a missing one, and hidden entries are left out of the listing. |
|
|
58
|
+
| GET | `/api/fs/stat?path=/abs` | `fs-read` `scope: 'system'` stat. Returns `{path, realPath, size, modifiedAt, isDirectory}` (`realPath` resolves symlinks). 400 `absolute path without .. required`; 404 for a missing path **and** for one on the sandbox hidden lists (same message); 403 on `EACCES` / `EPERM`. |
|
|
59
|
+
| GET | `/api/fs/raw?path=/abs` | `fs-read` `scope: 'system'` read: the file's bytes, streamed (`application/octet-stream` + `Content-Length`; the stream is destroyed if the client aborts). Same path rules and error shapes as `/api/fs/stat`; 400 `not a file` for a directory. |
|
|
58
60
|
| POST | `/api/fs/workspace/resolve` | Resolve `{path}` to an absolute path and run `ensureWorkspaceHalo()`. Returns `{id, path}`. Used by the workspace-picker on switch. 404 `path not found` when it doesn't exist; 400 `not a directory` for a file; 400 `filesystem root cannot be a workspace` for `/` (or a drive root) — `ensureWorkspaceHalo()` writes a whole `.halo/` scaffold, so those two shapes are refused before the write. Deeper system trees (`/usr`, `/etc`) are deliberately **not** banned. |
|
|
59
61
|
|
|
60
|
-
Absolute paths only. Purpose: Explorer's workspace picker and switching validation. `/api/fs/browse` is intentionally unrestricted (it's a read-only picker over the server's filesystem — same trust boundary as the admin cookie). Reading/writing files still goes through `/api/files/*` and remains project-sandboxed.
|
|
62
|
+
Absolute paths only. Purpose: Explorer's workspace picker and switching validation. `/api/fs/browse` is intentionally unrestricted (it's a read-only picker over the server's filesystem — same trust boundary as the admin cookie). Reading/writing files still goes through `/api/files/*` and remains project-sandboxed. The one exception is the read-only `/api/fs/stat` + `/api/fs/raw` + `/api/fs/browse?files=1` trio behind the `fs-read` extension capability: they take absolute paths (no `..` segment) and refuse the sandbox hidden lists (`~/.halo/secrets`, `~/.ssh`, `.git-credentials`, the global dbs … — `isHiddenHostPath` in `tools/sandbox.ts`, the same lists the agent sandbox masks) by **realpath**, so a symlink can't reach them. They are admin-cookie routes, never in `PUBLIC_PATHS`; `fs-read` itself is host-side UX gating (the extension iframe is same-origin and could call them directly), which is why the guard lives on the routes.
|
|
61
63
|
|
|
62
64
|
## Data Preview
|
|
63
65
|
|
|
@@ -94,6 +96,8 @@ Installed extensions are directories under `~/.halo/global/extensions/<id>/` wit
|
|
|
94
96
|
| GET | `/api/extensions/:id/:version/:token/<asset>` | **path token, no cookie** | Static file from the extension directory. 401 unless `verifyScopedToken(token,'ext')`; 404 if `version` ≠ installed version or the asset escapes the directory. Response headers: `Access-Control-Allow-Origin: *`, `Referrer-Policy: no-referrer`, `Cache-Control: public, max-age=31536000, immutable` (the version segment is the cache key) |
|
|
95
97
|
| WS | `/api/transcribe/stream?ext=<id>&lang=<auto\|xx-XX>` | cookie | Streaming transcription proxy to Amazon Transcribe (`routes/transcribe-ws.ts`; not mounted in AgentCore mode). Handshake 401 without the login cookie, 403 unless `ext` is installed and declares capability `transcribe`. Client → binary PCM s16le mono 16 kHz, text `{"type":"end"}` (flush, server closes 1000). Server → `{"type":"ready"}` (on connect; stream audio right after it), `{"type":"partial"\|"final",start,end,text,lang}` (seconds from the stream's first audio byte), `{"type":"error",code,message}` then close; `code` ∈ `credentials` \| `denied` \| `limit` \| `bad-request` \| `io`. Region / languages / credentials from the extension's `ext-<id>` settings (default chain when no keys) — see [design](../design/canvas-extensions.md#streaming-transcription-proxy) |
|
|
96
98
|
|
|
99
|
+
Extension capabilities the host understands (`ExtensionCapability`): `save`, `media`, `transcribe`, `fs-read`; an unknown value makes the manifest an error. `fs-read` adds no extension-specific route: the host serves the extension's scoped `fs` frames and `pick` file picker from the [Filesystem browse](#filesystem-browse-not-project-scoped--for-the-workspace-picker) routes above (`scope: 'system'`) and the ordinary `/api/files/*` routes (`scope: 'workspace'`) — see [design/canvas-extensions.md](../design/canvas-extensions.md#scoped-file-access-and-the-picker-fs-read).
|
|
100
|
+
|
|
97
101
|
**Why the asset route isn't cookie-authed.** The admin first hosted extensions in `<iframe sandbox="allow-scripts">` without `allow-same-origin` — an opaque origin. The document navigation still carries the cookie, but every subresource it loads (classic/module scripts, img, css, fetch, wasm, dynamic import, Worker) is a cross-site request with **no cookie**, and module/fetch/wasm need `Access-Control-Allow-Origin` too. A `?token=` query would be dropped when the document resolves `./viewer.js`-style relative URLs, so the token is a **path segment** that every relative URL inherits. The host now grants `allow-same-origin` (a cookie-auth proxy in front of halo — CloudFront + midway, oauth2-proxy — needs its *own* cookie on those requests, which only a same-origin iframe sends; see [design/canvas-extensions.md](../design/canvas-extensions.md#asset-serving-and-the-scoped-token)), so the admin cookie does travel again, but the route keeps the path token as its single auth path. `authMiddleware` lets exactly that path shape (`EXTENSION_ASSET_PATH = /^\/api\/extensions\/[^/]+\/[^/]+\/[^/]+\/./`) through without a cookie and the route verifies the token itself. The token is good for nothing else: `validateToken` (admin cookie / WS upgrade) refuses any JWT payload that carries a `scope`, so an asset token replayed as `halo_token` is a 401.
|
|
98
102
|
|
|
99
103
|
Install / uninstall don't broadcast themselves — the resulting rename / rm trips the `fs.watch` on `~/.halo/global/extensions/` (`extensions/watcher.ts`), the single notifier; both routes also call `rescanAndBroadcast()` so their response and the next `GET` already reflect the change. Changes reach every connected admin as the WS frame `extension:changed` (full snapshot — see [design/ws.md](../design/ws.md#other-server--client-messages)). All three cookie routes are "any logged-in admin" — the cookie has no access level, so install / delete cannot be restricted to full access (the agent-side `extension` skill is `requiresAccess: full`).
|
|
@@ -305,13 +309,70 @@ gets JSON, and additionally gates on `accessLevel`.
|
|
|
305
309
|
|---|---|---|
|
|
306
310
|
| 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
311
|
| 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`. |
|
|
312
|
+
| 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
313
|
| POST | `/api/web/stop` | Stop running task → `{stopped: boolean}` |
|
|
309
|
-
| GET | `/api/web/history
|
|
310
|
-
| GET | `/api/web/subscribe` | Reconnect SSE to running session |
|
|
314
|
+
| 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 |
|
|
315
|
+
| 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
316
|
| 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
317
|
|
|
318
|
+
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.
|
|
319
|
+
|
|
313
320
|
See [design/web.md](../design/web.md).
|
|
314
321
|
|
|
322
|
+
## A2A (Agent-to-Agent)
|
|
323
|
+
|
|
324
|
+
Files: `packages/server/src/a2a/routes.ts` (JSON-RPC handler), `exposure.ts` (path resolution, auth, card), `outbound.ts` (push receiver). A2A v1.0, JSON-RPC binding only. Mounted in normal server mode outside `/api/*` (no admin cookie) and before the static frontend; not in the CLI / TUI. AgentCore mode serves the same handler at `/` instead — see [AgentCore runtime container](#agentcore-runtime-container-halo-agentcore-only). Design: [design/a2a.md](../design/a2a.md).
|
|
325
|
+
|
|
326
|
+
| Method | Path | Purpose |
|
|
327
|
+
|---|---|---|
|
|
328
|
+
| POST | `/a2a/<rel>` (trailing `/` ok) | JSON-RPC 2.0 for the workspace `<A2A base>/<rel>` |
|
|
329
|
+
| GET | `/a2a/<rel>/.well-known/agent-card.json` | The agent card. `Cache-Control: private, max-age=300` + `ETag` (304 on `If-None-Match`); invalid `.halo/agent-card.json` → 500 |
|
|
330
|
+
| POST | `/a2a-push/:pushId` | Webhook receiver for remotes this server dispatched to (`a2a_send`) |
|
|
331
|
+
|
|
332
|
+
**Exposure**: `<rel>` is the workspace path relative to the base dir (`HALO_A2A_ROOT`, default the user's home), each segment percent-encoded. Only a workspace containing `.halo/agent-card.json` is served; empty / `.` / `..` segments, separators inside a segment, and symlinks leaving the base are refused. The card's interface URL is `general.a2a.public_url` (env `HALO_A2A_PUBLIC_URL`) + `/a2a/<rel>/`, else derived from the request host.
|
|
333
|
+
|
|
334
|
+
**Auth** (RPC and card alike): a web-channel token in `Authorization: Bearer <t>` or `x-token: <t>` — headers only, `?token=` is ignored. Same `resolveTokenAuth` core and `web-token` lockout bucket as the [Web channel](#public-endpoints-token-auth-via-x-token-header-or-token-query). Missing token → 401 `{error: "token required"}` (before any path lookup); invalid token, unknown / unexposed path, or a token bound to another workspace → the same 404 `{error: "not found"}`; lockout → 429. The token's access level becomes the session's (`readonly` / `observer` → the A2A read-only profile). A token never reaches another workspace. A GET of anything but the card path → 404.
|
|
335
|
+
|
|
336
|
+
**JSON-RPC**: header `A2A-Version: 1.0` required (missing → treated as 0.3 → `-32009`). Body ≤16 MB; batches refused. Answered HTTP 200 with a JSON-RPC body — errors included; only the auth failures above use HTTP statuses.
|
|
337
|
+
|
|
338
|
+
| Method | Params (main) | Result |
|
|
339
|
+
|---|---|---|
|
|
340
|
+
| `SendMessage` | `message { role: "ROLE_USER", parts, messageId?, contextId?, taskId?, metadata?["halo/interrupt"] }`, `configuration { returnImmediately?, taskPushNotificationConfig? }` | `{ task }`. Blocks until terminal (≤10 min, then the WORKING task) unless `returnImmediately: true` |
|
|
341
|
+
| `SendStreamingMessage` | as SendMessage | SSE: `{task}` snapshot → `statusUpdate` (interims) / `artifactUpdate` (`progress` live text) → terminal `{task}`, then close; `: keepalive` every 15 s |
|
|
342
|
+
| `GetTask` | `id`, `historyLength?` (ignored) | Task: `{ id, contextId, status { state, message?, timestamp }, artifacts, metadata?["halo/errorKind"] }` |
|
|
343
|
+
| `ListTasks` | `contextId?`, `status?`, `statusTimestampAfter?` (ISO-8601), `pageSize?` (1–100, default 50), `pageToken?`, `includeArtifacts?` | `{ tasks, nextPageToken, pageSize, totalSize }`, newest `updated_at` first; `nextPageToken` `""` on the last page; artifacts only with `includeArtifacts`, file bytes never (counted in `metadata["halo/omittedFiles"]`) |
|
|
344
|
+
| `CancelTask` | `id` | The CANCELED task (already canceled → returned as is) |
|
|
345
|
+
| `SubscribeToTask` | `id` | SSE as above, for a live task |
|
|
346
|
+
| `CreateTaskPushNotificationConfig` | `taskId`, `url`, `id?`, `token?`, `authentication? { scheme, credentials }` | The config. URL policy applies |
|
|
347
|
+
| `GetTaskPushNotificationConfig` | `taskId`, `id` | The config |
|
|
348
|
+
| `ListTaskPushNotificationConfigs` | `taskId` | `{ configs, nextPageToken: "" }` |
|
|
349
|
+
| `DeleteTaskPushNotificationConfig` | `taskId`, `id` | `null` (idempotent) |
|
|
350
|
+
| `GetExtendedAgentCard` | — | `-32004` |
|
|
351
|
+
|
|
352
|
+
Parts: `{ text }`, and images `{ raw: <base64>, mediaType, filename? }` / `{ url, mediaType? }` — png / jpeg / gif / webp, ≤5 MB each, ≤10 MB per message. States: `TASK_STATE_WORKING` → `TASK_STATE_COMPLETED` (artifact `result`) / `TASK_STATE_FAILED` / `TASK_STATE_CANCELED` (artifact `partial` when there is output). A `contextId` is always server-minted (`a2a_<accountId>_…`, from an earlier response).
|
|
353
|
+
|
|
354
|
+
| Code | When |
|
|
355
|
+
|---|---|
|
|
356
|
+
| `-32700` | Body is not JSON |
|
|
357
|
+
| `-32600` | Not a JSON-RPC 2.0 request, a batch array, or a body over 16 MB |
|
|
358
|
+
| `-32601` | Unknown method |
|
|
359
|
+
| `-32602` | Invalid params: empty message, bad `role`, unknown or foreign `contextId`, `taskId` not in `contextId`, image fetch / limit failure, refused push URL (`push url not allowed: <reason>`), bad `pageSize` / `pageToken` / `status` / timestamp |
|
|
360
|
+
| `-32603` | Internal error; the workspace couldn't be opened |
|
|
361
|
+
| `-32001` | Task (or push config) not found — includes another account's task |
|
|
362
|
+
| `-32002` | CancelTask on a COMPLETED / FAILED task |
|
|
363
|
+
| `-32004` | Unsupported: `taskId` of a terminal task, SubscribeToTask / push-config create on a terminal task, `GetExtendedAgentCard` |
|
|
364
|
+
| `-32005` | A part that is neither text nor a supported image (incl. `data` parts) |
|
|
365
|
+
| `-32009` | `A2A-Version` missing or not `1.0` |
|
|
366
|
+
|
|
367
|
+
**Push delivery** (to a caller's webhook): `POST <url>` with `Content-Type: application/a2a+json`, `A2A-Version: 1.0`, `X-A2A-Notification-Token: <token>` when the config has one, `Authorization: <scheme> <credentials>` when it has `authentication`. Body `{ task }` on a terminal state, `{ statusUpdate }` for an interim. The pushed task carries no file bytes: text and status stay, file parts are counted in `metadata['halo/omittedFiles']`, and GetTask returns them. Each event is sent once per config; non-2xx retries with backoff (4xx other than 408 / 429 gives up at once). The URL must pass `general.a2a.url_allowlist`.
|
|
368
|
+
|
|
369
|
+
**`POST /a2a-push/:pushId`** (receiver): token in `X-A2A-Notification-Token` (or `Authorization: Bearer`), compared constant-time with the dispatch's; unknown id or wrong token → 404 `{error: "not found"}`. Body `{ task }` or `{ statusUpdate }` whose task id must belong to that dispatch (else 404 `{error: "unknown task"}`); invalid JSON → 400.
|
|
370
|
+
|
|
371
|
+
The push is a doorbell: only the task id is read from the body. The receiver then runs its own authed GetTask on the remote, with a 10 s deadline:
|
|
372
|
+
- **GetTask succeeds:** answers `{ ok: true }` and injects the fetched Task into the calling session asynchronously — the report when it is terminal, an interim when it is WORKING with a status message.
|
|
373
|
+
- **GetTask fails** (network, timeout, HTTP or JSON-RPC error): answers **503** `{error: "task fetch failed, retry later"}` and delivers nothing, so the sender retries.
|
|
374
|
+
- **Dispatch already reported:** answers `{ ok: true }` without a fetch.
|
|
375
|
+
|
|
315
376
|
## Show (world snapshot)
|
|
316
377
|
|
|
317
378
|
File: `packages/server/src/routes/halo-city.ts`. Token auth (same `x-token` as
|
|
@@ -424,9 +485,17 @@ File: `packages/server/src/routes/evolution.ts`. Surfaces the global `evolution_
|
|
|
424
485
|
| DELETE | `/api/evolution/runs/:id` | Delete a finished run: its on-disk artifacts (run dir + archive zip + wrapper log) **and** the DB row, plus every apply that references it (artifacts + row, `evolution:apply_changed` `kind:'deleted'`). Rejected with 409 for in-flight states (`pending` / `running` / `approved`) so a live wrapper / queued apply isn't pulled out from under. Broadcasts `evolution:run_changed` with `kind:'deleted'`. |
|
|
425
486
|
| GET | `/api/evolution/applies` | List in-flight apply rows (`pending` / `running` / `syncing`, newest 200; used for status badges). Returns `{applies}`. |
|
|
426
487
|
|
|
427
|
-
## AgentCore
|
|
488
|
+
## AgentCore runtime container (`halo agentcore` only)
|
|
489
|
+
|
|
490
|
+
File: `packages/server/src/a2a/agentcore.ts`. Served **only** when `HALO_RUNTIME_MODE=agentcore-a2a` (set by `halo agentcore`; see [design/agentcore.md](../design/agentcore.md)), on port 9000, with no cookie / token auth — AgentCore verifies the caller's signed request before forwarding. Not present in normal server mode.
|
|
491
|
+
|
|
492
|
+
| Method | Path | Purpose |
|
|
493
|
+
|---|---|---|
|
|
494
|
+
| GET | `/ping` | `{"status":"HealthyBusy"}` while the workspace lease is held and a session is running or a push is pending, else `{"status":"Healthy"}` |
|
|
495
|
+
| POST | `/` | A2A JSON-RPC. Without the workspace lease: error `-32603` "workspace is in use by another runtime session…" |
|
|
496
|
+
| GET | `/.well-known/agent-card.json` | The agent card (`<ws>/.halo/agent-card.json`, seeded if missing); interface URL = `HALO_A2A_PUBLIC_URL` + `/`; no `securitySchemes` |
|
|
428
497
|
|
|
429
|
-
|
|
498
|
+
Any other path is 404. The former HTTP-protocol routes (`POST /invocations`, WS `/ws`) were removed 2026-10-09.
|
|
430
499
|
|
|
431
500
|
---
|
|
432
501
|
|
|
@@ -63,7 +63,7 @@ pnpm --filter @turmind/halo-admin build # next build + copy-monaco; never a ba
|
|
|
63
63
|
|
|
64
64
|
## 2. Runtime data locations
|
|
65
65
|
|
|
66
|
-
No directory needs to be created by hand. SQLite databases are created automatically on first use: per-workspace state at `<workspace>/.halo/halo.db`, plus global queues at `~/.halo/global/evo.db`, `~/.halo/global/cron.db` and `~/.halo/global/
|
|
66
|
+
No directory needs to be created by hand. SQLite databases are created automatically on first use: per-workspace state at `<workspace>/.halo/halo.db`, plus global queues at `~/.halo/global/evo.db`, `~/.halo/global/cron.db`, `~/.halo/global/runs.db` and `~/.halo/global/a2a.db`, and channel accounts at `~/.halo/secrets/channels/channels.db`.
|
|
67
67
|
|
|
68
68
|
## 3. Run `halo setup`
|
|
69
69
|
|
|
@@ -219,7 +219,8 @@ 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.
|
|
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
|
+
| `halo agentcore --workspace <path>` | Foreground A2A server for an Amazon Bedrock AgentCore Runtime container — see [design/agentcore.md](../design/agentcore.md) |
|
|
223
224
|
|
|
224
225
|
### Upgrade flow
|
|
225
226
|
|
|
@@ -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
|