@turmind/halo 1.5.9 → 1.5.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/{597-399e2e1de26aa747.js → 941-4719e7343b54b9bd.js} +1 -1
- package/admin-out/_next/static/chunks/989-86b0f677f69ccf3b.js +1 -0
- package/admin-out/_next/static/chunks/app/page-4792aa1c79f7e7f1.js +1 -0
- package/admin-out/_next/static/css/97f3ac570664d044.css +3 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/agent.md +7 -5
- package/bundled-docs/design/canvas-extensions.md +14 -2
- package/bundled-docs/design/express-self.md +4 -4
- package/bundled-docs/design/halo-city.md +6 -0
- package/bundled-docs/design/prompt-system.md +1 -1
- package/bundled-docs/design/relay.md +6 -6
- package/bundled-docs/design/session.md +12 -11
- package/bundled-docs/design/web.md +33 -5
- package/bundled-docs/dev/acp-adapter.md +59 -175
- package/bundled-docs/dev/api.md +5 -2
- package/bundled-docs/dev/deploy.md +1 -1
- package/bundled-docs/dev/desktop-packaging.md +14 -0
- package/bundled-docs/dev/env.md +2 -2
- package/bundled-docs/dev/tools.md +8 -8
- 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 -8
- 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 +1 -2
- package/bundled-docs/guide/getting-started.md +1 -1
- package/bundled-docs/guide/skills.md +1 -2
- package/bundled-docs/requirements/chat.md +28 -8
- package/bundled-docs/requirements/command.md +1 -2
- package/bundled-docs/requirements/editor.md +1 -0
- package/bundled-docs/requirements/explorer.md +1 -0
- package/bundled-docs/requirements/terminal.md +15 -0
- package/dist/index.js +810 -333
- package/package.json +1 -1
- package/templates/agents/__score__/AGENT.md +1 -1
- package/templates/agents/default/agent.yaml +0 -1
- package/templates/prompts/all/TOOL_SHELL.windows.md +2 -1
- package/templates/skills/skill/SKILL.md +1 -1
- package/templates/skills/workspace/stage.py +1 -1
- package/admin-out/_next/static/chunks/989-c404f6f43c400911.js +0 -1
- package/admin-out/_next/static/chunks/app/page-59bfce35bbb30b05.js +0 -1
- package/admin-out/_next/static/css/27b44c85485eabb9.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/{RD5w3FmDZ6HtyQGGaUb8t → GO2W5JoiC46FIxLjtFAXx}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{RD5w3FmDZ6HtyQGGaUb8t → GO2W5JoiC46FIxLjtFAXx}/_ssgManifest.js +0 -0
|
@@ -114,7 +114,7 @@ interface AgentSession {
|
|
|
114
114
|
contextConfig: { maxTokens, compressAt }
|
|
115
115
|
isCompacting: boolean
|
|
116
116
|
foldAfterCompact: string | null // queue text a Stop folded while a compact was in flight; clearCompacting lands it on the post-compact history
|
|
117
|
-
interruptRequested: boolean // soft-interrupt flag — abort
|
|
117
|
+
interruptRequested: boolean // soft-interrupt flag — abort at the next batch boundary
|
|
118
118
|
selfKick: boolean // armed by the built-in continue_task tool; drainQueue turns it into one synthetic resume turn (one-turn lifetime)
|
|
119
119
|
resumedAfterInterrupt: boolean // this turn was started by drainQueue after an interrupt — the only state in which continue_task arms selfKick
|
|
120
120
|
workingDir: string | null // resolved working directory (null = project root)
|
|
@@ -208,9 +208,9 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
|
|
|
208
208
|
- **`batchBoundary` complete**: when a merged round finishes **and the queue still has a next round**, `drainQueue` emits `{ type: 'complete', batchBoundary: true }`. This is a per-round flush signal for **block-oriented channels** (wechat / telegram / slack / feishu / wecom), which buffer streamed text and only ship a message on `complete` — without it, N drain rounds buffer into one blob that lands only at the terminal `complete` ("8 reports in one lump"). **Stream-terminating consumers (web-channel SSE, ACP) must ignore `batchBoundary` and keep the stream open** — the root is still running and more output follows; only the terminal (unmarked) `complete` closes the stream. See [web.md](web.md) and the per-channel coalescing notes (e.g. [wechat.md](wechat.md)). The WS `chat:complete` frame carries the field too — admin closes its bubble either way but rings the finish bell only on the terminal one.
|
|
209
209
|
|
|
210
210
|
**Three-tier interrupt model** — two **soft** paths and one **hard** path, distinguished by *when* the abort fires, never by whether the message is kept (all three preserve the queue):
|
|
211
|
-
1. **`query_session` while busy — soft interrupt.** `querySession(..., interrupt=false)` enqueues **and** sets `interruptRequested` (same as a user message). This is what makes a sub-agent fold two queued questions into **one merged answer** instead of replying one-by-one: the in-flight turn unwinds after its current tool, then `drainQueue`'s `splice(0)` folds every message that landed alongside it into a single round — matching how root handles two user messages. An **idle** target has no turn to interrupt, so it just runs the message directly.
|
|
212
|
-
2. **User message while busy — soft interrupt.** `sendUserMessage` (busy branch) pushes the message and sets `interruptRequested`. The graceful point is **
|
|
213
|
-
3. **`interrupt_session` — hard interrupt.** `querySession(..., interrupt=true)` enqueues, then `interruptSession` aborts **immediately** (not at the next
|
|
211
|
+
1. **`query_session` while busy — soft interrupt.** `querySession(..., interrupt=false)` enqueues **and** sets `interruptRequested` (same as a user message). This is what makes a sub-agent fold two queued questions into **one merged answer** instead of replying one-by-one: the in-flight turn unwinds after its current tool batch, then `drainQueue`'s `splice(0)` folds every message that landed alongside it into a single round — matching how root handles two user messages. An **idle** target has no turn to interrupt, so it just runs the message directly.
|
|
212
|
+
2. **User message while busy — soft interrupt.** `sendUserMessage` (busy branch) pushes the message and sets `interruptRequested`. The graceful point is the **batch boundary**: `runAgentTurn`'s `beforeCallModel` hook aborts right before the *next* model call, so the current `tool_use` batch always runs to completion — a mid-flight `shell_exec` is **not** killed and no call in a parallel batch is skipped — then the turn unwinds and `drainQueue` folds the message into the next round. Never before the turn's **first** batch: a message that lands during the first model call lets the turn proceed (it merges at the next boundary, or drains after the turn ends), so a turn never ends on a bare user message. Nothing is pending at a boundary, so the soft path emits no UI marker and leaves nothing for repair.
|
|
213
|
+
3. **`interrupt_session` — hard interrupt.** `querySession(..., interrupt=true)` enqueues, then `interruptSession` aborts **immediately** (not at the next batch boundary), so a mid-flight command **is** SIGTERM'd. For this to actually kill a *compound* command (`sleep 60 && …`), the `full`-access shell path runs the command as a **process-group leader** and signals the whole group — see [Process-group kill](#process-group-kill-on-abort) below. The enqueued message drains on the same wake-up.
|
|
214
214
|
|
|
215
215
|
**`continue_task` — resume after interrupt.** All three interrupt paths share a say/do gap: the interrupted turn unwinds, `drainQueue` runs the new message as a fresh turn, the model answers it and `end_turn`s — it *says* "continuing" but the interrupted work never resumes (worse for sub-agents, whose answer is then auto-reported to the parent as if it were the finished result). The built-in `continue_task` tool (every agent, no params — the one truly unconditional tool: `activate_skill` is gated on `skills`, session tools on `team`; wired in `session-agent-builder`) closes it with a flag pair on `AgentSession`:
|
|
216
216
|
- **`resumedAfterInterrupt`** is snapshotted from `interruptRequested` at the top of every drained batch (before the per-batch reset) and set `false` for the opening turn. `requestSelfKick` — the tool backend — returns `not_interrupted` and sets nothing unless it is `true`, so a normal turn can't arm the flag: **kicks ≤ interrupts**, no perpetual motion.
|
|
@@ -219,7 +219,7 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
|
|
|
219
219
|
- **Interim report at turn end** (`sendInterimReport`, called in `drainQueue` right after `runAgentTurn` returns, before the kick block): the next turn resets `output` / `finalOutput`, and every end-of-run report (`tryReportToParent` / `deliverRelayReport`) reads only the last turn — so a turn that *another turn follows* would never reach whoever asked. Trigger: `(selfKick && !interruptRequested) || messageQueue.length > 0` (a kick is coming, or a message is already queued — the kick itself is skipped then, the interim must not be) **and** the turn was an answer (`selfKick` set, or `finalOutput !== ''` from a natural `end_turn`). An esc after `continue_task` fails the first half → no kick → the run ends → the final report carries the turn, no interim. The per-turn reset stays (get_session_output, the session file and the report cap depend on it); instead the turn's text is snapshotted here and forwarded **only when the batch came from the party this session owes a report** — the pure `interimDoor(parentId, batch)` picks the recipient (`'parent'` / `'relay'` / null): a sub-agent whose batch has a message with `sourceSessionId === parentId` → `querySession(parent, self, "[Interim report · status: still running] … Not the final result.\n\n<answer>")`, **without** stamping `stoppedAt` or emitting `agent_done` (the parent's sibling-status still lists the child as running → "Do NOT wrap up yet"; the real auto-report follows at the end); a root whose batch has a relay-prefixed message → `deliverRelayInterim` (keeps `reply_to`, see [relay.md → Interim report](relay.md#interim-report-continue_task)). Both paths share `deliverInterim(session, 'parent' | 'relay')`. The body is the **whole** turn's `output`, not `finalOutput`: the response carrying the `continue_task` tool_use is `stopReason: tool_use`, so its text is `final: false` and never lands in `finalOutput` — an answer written before the call would otherwise be dropped. Nothing for local user chat (the channel already streamed the answer and a `batchBoundary` complete flushes it), a sibling/other-session message, or an errored turn. Capped at `limits.autoReportMax`.
|
|
220
220
|
- **Opening-turn interim** (`sendOpeningInterim`, in `runSession`'s `runFn` right after the `hasFirst` turn): the same loss happens when a follow-up is queued while the opening turn runs — the drain's first turn resets the opening wrap-up. Fires when `messageQueue` is non-empty, `finalOutput !== ''` and no `turnError`. A sub-agent's opening message is its parent's dispatch, so it always owes the parent. A root owes its relay caller only when the opening message itself carries the relay prefix (same `interimDoor` as the drain path) **and** a `reply_to` row exists — a pending `reply_to` alone is not enough, or a local user chatting into a root that still owes a relay report would have their answer forwarded to the caller; otherwise nothing.
|
|
221
221
|
- **Deferred interim** (`sendDeferredInterim`, in `runSession`'s finally right before `tryReportToParent` / `deliverRelayReport`): a session waiting on its own sub-agents is asked by its caller (`relay_send` / the parent's `query_session`) and answers, and the run ends with a child still running — both end-of-run reports skip (subtree not quiet), and the child's later report wakes a new run whose first turn resets `output`, so the answer reached no one. The run's last turn (`RunLastTurn { batch, waitingOnChildren }`, refreshed before the opening turn and every drained batch) sends an interim through `sendInterimReport` when it answered the owed party (`interimDoor`) **while a child was already running at turn start** (`waitingOnChildren` — door first, db lookup only past it, so local chat / child reports / goal rounds / kicks never query; turn *start*, because the dispatch turn starts its child mid-turn and owes the final report, not an interim), `finalOutput !== ''`, no `turnError`, and the subtree is still busy at run end (`listActiveChildren`, the same db gate the two reports read synchronously right after: children running → they skip and this sends; none → they report the turn as final and this doesn't). `reply_to` is kept, so the final report still fires once. Never overlaps the drain path: a turn another turn follows is never the last.
|
|
222
|
-
- **Stop / delete / archive never resurrect**: `stopSession` and `stopUserSession` clear `selfKick` next to `interruptRequested`; `deleteSession` (root + children) and `archiveSession` clear it right after nulling `abortController` and *before* awaiting `session.promise` (the aborted turn's drain still runs inside that promise and would otherwise fire a real kick turn); and `requestSelfKick` returns `no_turn` when `session.abortController === null` — Stop nulls it *before* the abort lands, so a tool callback that completes after a Stop can't re-arm the flag. `enqueueUserMessage` (admin WS) mirrors `sendUserMessage` and does **not** set `interruptRequested` while a compact runs outside a live model loop (manual `/compact`, or the turn-end auto-compact — `abortController` already null) — there is no live turn, and the drain would otherwise snapshot it into `resumedAfterInterrupt` for a turn that interrupted nothing. A mid-turn auto-compact (`beforeCallModel`, `abortController` still set) does sit inside a live turn, so the flag is set and the turn yields
|
|
222
|
+
- **Stop / delete / archive never resurrect**: `stopSession` and `stopUserSession` clear `selfKick` next to `interruptRequested`; `deleteSession` (root + children) and `archiveSession` clear it right after nulling `abortController` and *before* awaiting `session.promise` (the aborted turn's drain still runs inside that promise and would otherwise fire a real kick turn); and `requestSelfKick` returns `no_turn` when `session.abortController === null` — Stop nulls it *before* the abort lands, so a tool callback that completes after a Stop can't re-arm the flag. `enqueueUserMessage` (admin WS) mirrors `sendUserMessage` and does **not** set `interruptRequested` while a compact runs outside a live model loop (manual `/compact`, or the turn-end auto-compact — `abortController` already null) — there is no live turn, and the drain would otherwise snapshot it into `resumedAfterInterrupt` for a turn that interrupted nothing. A mid-turn auto-compact (`beforeCallModel`, `abortController` still set) does sit inside a live turn, so the flag is set and the turn yields at that same boundary once the compact returns, instead of running to its natural end with the message still queued.
|
|
223
223
|
|
|
224
224
|
**Stop preserves, archive discards.** Both `stopSession` and `stopUserSession` (the user Stop button) fold the **whole** `messageQueue` into `agent.messages` as a user turn (user entries raw, agent entries with their `(from: session X)` prefix) **before** aborting, then `repairConversationMessages` — a stop **parks** the work, it never drops a queued message (mirrors interrupt, which also never loses one). While a compact is in flight the fold is parked in `foldAfterCompact` and lands when the compact ends (see [Compaction paths](#compaction-paths)). `archiveSession`, by contrast, clears the queue without folding — archiving a subtree is a deliberate discard.
|
|
225
225
|
|
|
@@ -251,6 +251,8 @@ Two event-field notes (`agents/agent-events.ts`):
|
|
|
251
251
|
|
|
252
252
|
The five session tools that take an existing `session_id` — `query_session`, `interrupt_session`, `stop_session`, `archive_session`, `get_session_output` — are scoped to the **caller's own session tree**. The check is the same root-prefix primitive used everywhere else: `targetId.split('>')[0] === callerId.split('>')[0]` (mirrors `findRootSessionId`). A cross-tree id is refused as `{"code": 1, "error": "session <id> not found"}` — not-found phrasing avoids leaking a foreign session's existence.
|
|
253
253
|
|
|
254
|
+
Within the tree, `stop_session` / `archive_session` (and `relay_stop` aimed at the caller's own workspace) also refuse the caller's **own session or an ancestor** (`callerId === targetId || callerId.startsWith(targetId + '>')` — `isSelfOrAncestor` in `session-tools.ts`). Both cascades await every running turn in the target's subtree, and that subtree contains the turn running the tool call, so the await never settled and the turn hung until restart. `interrupt_session` on self or an ancestor does not hang — `interruptSession` is fire-and-forget — and stays allowed. (`relay_send` / `relay_interrupt` refuse self / an ancestor in the own workspace too, for a different reason: the relay report would land back in the caller's own tree — see [relay.md](relay.md).)
|
|
255
|
+
|
|
254
256
|
Why it's needed: one `SessionManager` per workspace holds **every** user's/channel's session trees (root ids are prefixed `web_<acct>_…` / `tg_<userId>_…` / …). Session ids are hierarchical strings and the full id is enumerable — a child knows its own id (and thus its root, the left-most segment), and any agent with `file_read` can read `.halo/sessions/<agentId>/<sid>.json`, which persists each session's `id` + `parentSessionId`. Without the gate, a prompt-injected agent on one tree could enumerate a foreign root id and `archive` (irreversible cascade), `stop` (DoS), or `get_session_output` (read transcript) another user's tree. This is the only by-id session entrypoint; the user-facing paths (`/switch`, `/session info`/`/tree`, `visibleSessions`) already enforce the same prefix scoping.
|
|
255
257
|
|
|
256
258
|
The gate lives at the **tool callback layer** (`agents/session-tools.ts`), not inside the SessionManager methods — those same methods are also reached by already-authorized user paths and by the internal auto-report (`tryReportToParent` → `querySession(parentId, …)`, always in-tree), so an in-method check would break legitimate callers. `start_session` / `query_agent` gate at the same callback layer (on agent_id + team), so all delegation authorization sits in one place. Covered by `test/session-agent-builder.test.ts` (cross-tree refused without touching the target; same-tree passes through).
|
|
@@ -310,7 +312,7 @@ A `toolUseId`-based algorithm that repairs message arrays damaged by abort / int
|
|
|
310
312
|
|
|
311
313
|
**Why synthesize instead of strip** (Phase 2): stripping an orphaned `toolUse` made the model believe the call *never happened*, so after an Esc / `interrupt_session` / stop aborted an in-flight tool, the next turn dutifully re-issued the same call — an interrupted `sleep 30` re-ran in full, doubling time and tokens. The synthesized result keeps the pair protocol-valid *and* tells the model the call was cut short, with wording that steers it away from an automatic retry. It lives in the shared repair path (not at each abort call site) because every interrupt flavor, crash recovery on reload, and the API-400 repair-retry all funnel through here. Idempotent: a synthesized result pairs its `toolUse`, so a later pass sees a match and does nothing.
|
|
312
314
|
|
|
313
|
-
**Partial batches land, only the cut tool is synthesized** (`agent-loop.ts`, the tool loop's `try/finally`): in a parallel `tool_use` batch the loop pushes the accumulated `tool_result` user message in a `finally`, so both interrupt exits — the loop's own cancel
|
|
315
|
+
**Partial batches land, only the cut tool is synthesized** (`agent-loop.ts`, the tool loop's `try/finally`): in a parallel `tool_use` batch the loop pushes the accumulated `tool_result` user message in a `finally`, so both hard-interrupt exits — the loop's own cancel checks (the abort lands while a tool runs, or between tools) and the consumer breaking its `for await` (finishes the generator at the `yield` via `.return()`) — still land the results that finished. A soft interrupt never cuts a batch (it waits for the batch boundary, see the [three-tier model](#message-queue-and-drain)). Before this, either exit skipped the push, so every completed result was lost and repair marked the **whole** batch `[interrupted]`; the model then re-ran work that had already happened, which for side-effecting calls (commit, append, send) is a double execution — and `continue_task`'s "re-issue interrupted calls" instruction made that hazard live. The tool the abort landed on is dropped on purpose (its result is a killed shell's partial output), so repair pairs exactly that id with the do-not-retry marker. Calls the batch never started are answered in the same `finally` with an `is_error` "was not run — the turn was interrupted before this call started. Safe to re-issue if still needed." result (the same land-before-exit pattern as the `max_tokens` not-run results), so repair's marker only ever meets a call that was genuinely cut mid-run.
|
|
314
316
|
|
|
315
317
|
**Partial streamed text lands** (`agent-loop.ts`, `run()`'s model-call `try/finally`): every streaming provider re-establishes the non-streaming contract on a cancel — it throws `AbortError` and returns no partial result — so the text already streamed existed only as `text_delta` events. The UI log persisted it (`appendStream`), the user read it, but `agent.messages` had no assistant turn: the next user message coalesced into the dangling user turn and, asked "what did you just write?", the model answered it had no record. The loop now accumulates `text_delta`s per model call and, when the call did **not** resolve and the **caller's** `cancelSignal` is aborted, pushes them as one assistant text block suffixed with `INTERRUPTED_REPLY_MARKER` (`[reply interrupted by the user here — the text above is what was shown before the cut]`). Same two exits as the tool batch above: the provider's rethrown `AbortError`, and the consumer breaking its `for await` at a delta `yield` (`runAgentTurn`'s `if (signal.aborted) break` → `.return()` → `finally`). Deliberately narrow: **text only** — a partial thinking block has no signature and Anthropic / Bedrock reject unsigned thinking in history, and a partial `tool_use` never reaches this point (the providers throw rather than hand back a half-built input that would be JSON-parsed / paired with a synthetic `tool_result` and make the model believe it made the call). **Gated on the caller's signal, not the idle-timeout controller**: a `MODEL_TIMEOUT_ERROR` is retried by `runAgentTurn` and the retry re-streams from scratch, so landing there would duplicate the text. No new event is emitted (the UI already has the partial) and `output` / `finalOutput` are untouched. Repair is a no-op on the landed block (Phase 1 only strips *empty* text blocks); the next `run()` sees a trailing assistant turn and pushes the user message fresh instead of coalescing. An abort before the first text delta still leaves the dangling user turn, exactly as before.
|
|
316
318
|
|
|
@@ -318,12 +320,11 @@ A `toolUseId`-based algorithm that repairs message arrays damaged by abort / int
|
|
|
318
320
|
|
|
319
321
|
### UI-side interrupt marker (`markPendingToolCallsInterrupted`)
|
|
320
322
|
|
|
321
|
-
The synthesized `[interrupted]` result above is **model-facing only** (`agent.messages`). The session UI log has a parallel problem: on a hard abort, `runAgentTurn`'s consumer loop breaks on `signal.aborted` before it processes the in-flight tool's real `tool_result` event, so pending tool-call blocks stayed "running" forever. `SessionManager.markPendingToolCallsInterrupted(sessionId)` scans the session's cached UI state for tool calls with no output yet and emits a synthetic `
|
|
323
|
+
The synthesized `[interrupted]` result above is **model-facing only** (`agent.messages`). The session UI log has a parallel problem: on a hard abort, `runAgentTurn`'s consumer loop breaks on `signal.aborted` before it processes the in-flight tool's real `tool_result` event, so pending tool-call blocks stayed "running" forever. `SessionManager.markPendingToolCallsInterrupted(sessionId)` scans the session's cached UI state for tool calls with no output yet and emits a synthetic `tool_result` for each through the normal `emitEvent` pipeline — ui-log-builder persistence, admin WS push, and TUI rendering pick it up unchanged. The batch runs serially and `setToolResult` fills the first pending entry, so the **first** pending call is the one that was running → `[interrupted by user]` (model side: repair's do-not-retry marker); the rest never started → `[not run — interrupted]` (model side: agent-loop's "not run" result). It never touches `agent.messages`. Called at the three hard abort sites (the soft interrupt waits for the batch boundary, where nothing is pending):
|
|
322
324
|
|
|
323
|
-
1.
|
|
324
|
-
2. **`
|
|
325
|
-
3. **`
|
|
326
|
-
4. **`stopUserSession`** (the user Stop button).
|
|
325
|
+
1. **`interruptSession`** (hard interrupt).
|
|
326
|
+
2. **`stopSession`**'s cascade — per descendant, before awaiting the aborted promise (runSession's finally emits `complete`, which flushes and clears the pending buffers this scans).
|
|
327
|
+
3. **`stopUserSession`** (the user Stop button).
|
|
327
328
|
|
|
328
329
|
Idempotent — completed tools (output already set) are never overwritten. Two supporting details: `session-ui-store.flushSubSession()` writes a stopped sub-session's log to its own file immediately (the synthetic results only arm the 500ms debounce, and the interrupt is a turn boundary — the marker shouldn't ride a timer); `ui-log-builder.setToolResult` attaches an incoming result to the **first** entry without an output (not the last entry) and never overwrites a completed one — fixes reversed attachment under parallel tool calls and keeps the marker idempotent.
|
|
329
330
|
|
|
@@ -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":"
|
|
86
|
-
data: {"type":"tool_result","toolName":"
|
|
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>_*`) —
|
|
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
|
|
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 —
|
|
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
|
@@ -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
|
|
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.
|
|
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
|
|