@turmind/halo 1.4.6 → 1.5.1-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +0 -1
  2. package/admin-out/404.html +1 -1
  3. package/admin-out/_next/static/chunks/160.c52d3d6cf7bc8286.js +1 -0
  4. package/admin-out/_next/static/chunks/220.e250bbbf65d24094.js +1 -0
  5. package/admin-out/_next/static/chunks/362.a0c58d3bf9b1300b.js +1 -0
  6. package/admin-out/_next/static/chunks/449.c4b881b9ebdc556e.js +1 -0
  7. package/admin-out/_next/static/chunks/613.fb09e55e0a61d3fe.js +1 -0
  8. package/admin-out/_next/static/chunks/73.05abce37d312e880.js +1 -0
  9. package/admin-out/_next/static/chunks/803-7128009ee0e1c10e.js +1 -0
  10. package/admin-out/_next/static/chunks/839.a2729fd33013d763.js +1 -0
  11. package/admin-out/_next/static/chunks/868.bbb91afd48c9eb58.js +1 -0
  12. package/admin-out/_next/static/chunks/892.a35118e09d8d8efd.js +1 -0
  13. package/admin-out/_next/static/chunks/989-7420ee650083df43.js +1 -0
  14. package/admin-out/_next/static/chunks/app/page-fa5e935dbaa74165.js +1 -0
  15. package/admin-out/_next/static/chunks/webpack-1a264efd965db7ab.js +1 -0
  16. package/admin-out/_next/static/css/b8ffb5202b5a6619.css +3 -0
  17. package/admin-out/index.html +1 -1
  18. package/admin-out/index.txt +5 -5
  19. package/bundled-docs/design/agent.md +8 -7
  20. package/bundled-docs/design/architecture.md +9 -5
  21. package/bundled-docs/design/canvas-extensions.md +183 -0
  22. package/bundled-docs/design/cron.md +1 -1
  23. package/bundled-docs/design/express-self.md +9 -7
  24. package/bundled-docs/design/feishu.md +3 -1
  25. package/bundled-docs/design/observability.md +2 -0
  26. package/bundled-docs/design/prompt-system.md +6 -10
  27. package/bundled-docs/design/relay.md +22 -16
  28. package/bundled-docs/design/session.md +38 -26
  29. package/bundled-docs/design/slack.md +2 -2
  30. package/bundled-docs/design/storage.md +5 -5
  31. package/bundled-docs/design/telegram.md +4 -2
  32. package/bundled-docs/design/wechat.md +7 -5
  33. package/bundled-docs/design/wecom.md +2 -2
  34. package/bundled-docs/design/ws.md +29 -22
  35. package/bundled-docs/dev/add-model-provider.md +21 -18
  36. package/bundled-docs/dev/api.md +25 -24
  37. package/bundled-docs/dev/deploy.md +39 -9
  38. package/bundled-docs/dev/desktop-packaging.md +18 -8
  39. package/bundled-docs/dev/env.md +2 -3
  40. package/bundled-docs/dev/previews.md +9 -1
  41. package/bundled-docs/dev/tools.md +17 -82
  42. package/bundled-docs/guide/channels/wechat.md +1 -1
  43. package/bundled-docs/guide/chat.md +4 -2
  44. package/bundled-docs/guide/cli.md +0 -1
  45. package/bundled-docs/guide/delegation-and-access.md +1 -2
  46. package/bundled-docs/guide/workspace.md +11 -0
  47. package/bundled-docs/requirements/chat.md +11 -8
  48. package/bundled-docs/requirements/command.md +1 -12
  49. package/bundled-docs/requirements/editor.md +10 -0
  50. package/bundled-docs/requirements/settings.md +9 -0
  51. package/dist/index.js +2168 -1199
  52. package/package.json +2 -2
  53. package/templates/agents/deep-executor/agent.yaml +0 -3
  54. package/templates/agents/default/AGENT.md +0 -17
  55. package/templates/agents/default/agent.yaml +0 -1
  56. package/templates/agents/executor/agent.yaml +1 -1
  57. package/templates/models/aws-bedrock-claude-invoke.yaml +31 -0
  58. package/templates/models/aws-bedrock-mantle.yaml +42 -7
  59. package/templates/models/aws-bedrock-openai.yaml +53 -17
  60. package/templates/prompts/root/DELEGATION.md +58 -0
  61. package/templates/skills/extension/SKILL.md +70 -0
  62. package/templates/skills/extension/templates/ext.sh +112 -0
  63. package/templates/skills/halo/SKILL.md +3 -0
  64. package/templates/skills/send-file/SKILL.md +1 -1
  65. package/admin-out/_next/static/chunks/220.1ed70c874686122f.js +0 -1
  66. package/admin-out/_next/static/chunks/23-092ac3901411582e.js +0 -1
  67. package/admin-out/_next/static/chunks/362.5970ef15ec3d7b15.js +0 -1
  68. package/admin-out/_next/static/chunks/449.a403f7ad18054e3f.js +0 -1
  69. package/admin-out/_next/static/chunks/613.f314fc12e05a566c.js +0 -1
  70. package/admin-out/_next/static/chunks/73.a4617a551a8e176c.js +0 -1
  71. package/admin-out/_next/static/chunks/767.16a0b33b6f5e7965.js +0 -1
  72. package/admin-out/_next/static/chunks/839.e381287756453ffe.js +0 -1
  73. package/admin-out/_next/static/chunks/868.cc7fd84c9f2fd711.js +0 -1
  74. package/admin-out/_next/static/chunks/892.92b2ed455683b49a.js +0 -1
  75. package/admin-out/_next/static/chunks/989-6a0ec35b8ae2cb39.js +0 -1
  76. package/admin-out/_next/static/chunks/app/page-bf9c0b21ae431c92.js +0 -1
  77. package/admin-out/_next/static/chunks/webpack-ca926228f8cba1ff.js +0 -1
  78. package/admin-out/_next/static/css/c0ea31cd787f7aa7.css +0 -3
  79. package/bundled-docs/design/goal-mode.md +0 -144
  80. /package/admin-out/_next/static/{qYSILjqBjfgTggO0T8E7T → gRsZxPP4kTOmCtkHhJIZB}/_buildManifest.js +0 -0
  81. /package/admin-out/_next/static/{qYSILjqBjfgTggO0T8E7T → gRsZxPP4kTOmCtkHhJIZB}/_ssgManifest.js +0 -0
@@ -62,8 +62,8 @@ Both directions are typed in `packages/core/src/protocol/ws-frames.ts` (`WsClien
62
62
  | `subscribe` | Subscribe to a session (load history, re-attach detached) |
63
63
  | `chat` | Send a user message (queued when the agent is busy); acked per `clientMsgId` — see [Chat delivery](#chat-delivery-ack--resend--dedup) |
64
64
  | `__ping__` | Application-level liveness probe; server replies `__pong__` |
65
- | `chat:stop` | Hard-abort the current generation (ends the turn, no re-run) → `stopUserSession` |
66
- | `chat:interrupt` | Interrupt the in-flight turn now (aborts a command mid-run); the server then folds any queued messages into one follow-up turn → `interruptSession`. Admin chat esc maps to this. A compacting session cancels the compact instead (same as `chat:stop`). |
65
+ | `chat:stop` | Hard-abort the current generation (ends the turn, no re-run) → `stopUserSession`. During a manual `/compact` (compacting, no turn running) it cancels the compact instead (`cancelCompact`, history rolls back). During an auto-compact — which runs inside a turn and is not cancellable — the abort takes effect once the compact finishes. |
66
+ | `chat:interrupt` | Interrupt the in-flight turn now (aborts a command mid-run); the server then folds any queued messages into one follow-up turn → `interruptSession`. Admin chat esc maps to this. Same compact semantics as `chat:stop`: a manual `/compact` with no turn running is cancelled; an auto-compact finishes first, then the interrupt lands. |
67
67
  | `session:clear` | Non-destructive /session new: save the current, release its listener, create fresh (handled inline) — see [Command dispatch](#command-dispatch) |
68
68
  | `session:delete` | Delete session files + cascade-delete descendants in SQLite (handled inline) |
69
69
  | `exchange:delete` | Delete one exchange (a user turn + its responses): **soft-delete** in the UI log (`deleted: true` markers, kept visible/greyed) + **physical-delete** the whole turn from `rawMessages` (LLM context) → `deleteExchange`. Fields: `userOrdinal` (0-based index among *main-conversation* user turns, i.e. excluding `taskId` sub-agent messages — matches the admin's `isMainConversationMessage` count), `archiveCount` (the archived-segment count the client's view was opened against — its archive anchor, from the `state:snapshot` it got on subscribe), optional `sessionId` / `projectId`. Rejected (→ `error`) while the session is running or compacting. The server also compares `archiveCount` against the on-disk header count (`readArchiveCount`); only a **mismatch** is refused, with `code: 'archived'` ("… archived history since it was opened — reopen it to delete individual turns") — a session with archives can still be deleted from as long as the anchor matches. See [session.md](session.md#exchange-deletion-soft-ui--hard-raw). |
@@ -82,31 +82,37 @@ Optional `chat` fields:
82
82
 
83
83
  ## Server → Client
84
84
 
85
- Source: [event-processor.ts:48-97](../../../packages/server/src/ws/event-processor.ts#L48) `sendWsNotification` switch.
85
+ Source: [event-processor.ts:53-129](../../../packages/server/src/ws/event-processor.ts#L53) `sendWsNotification` switch.
86
86
 
87
87
  ### Agent event → WS message mapping
88
88
 
89
89
  | Agent event | WS type | Fields |
90
90
  |---|---|---|
91
- | `thinking` | `chat:thinking` | text, agentName, taskId, turnId |
92
- | `stream` | `chat:stream` | text, agentName, taskId, turnId |
93
- | `agent_start` | `agent:start` | agentName, task, taskId |
94
- | `agent_done` | `agent:done` | agentName, taskId |
95
- | `tool_call` | `agent:tool_call` | tool, toolUseId, input, agentName, taskId, turnId |
96
- | `tool_result` | `agent:tool_result` | result, toolUseId, agentName, taskId, durationMs |
97
- | `followup_start` / `queued_message` | `chat:followup` | agentName |
98
- | `usage` (no taskId) | `chat:usage` | contextTokens, outputTokens, turnId, modelId, usage |
91
+ | `thinking_delta` | `chat:thinking` | text, agentName, taskId, turnId, sessionId |
92
+ | `stream_delta` | `chat:stream` | text, agentName, taskId, turnId, sessionId |
93
+ | `thinking` | `chat:thinking` | text, agentName, taskId, turnId, sessionId — **not forwarded** when `streamed: true` |
94
+ | `stream` | `chat:stream` | text, agentName, taskId, turnId, sessionId — **not forwarded** when `streamed: true` |
95
+ | `agent_start` | `agent:start` | agentName, task, taskId, sessionId |
96
+ | `agent_done` | `agent:done` | agentName, taskId, sessionId |
97
+ | `tool_call` | `agent:tool_call` | tool, toolUseId, input, agentName, taskId, turnId, sessionId |
98
+ | `tool_result` | `agent:tool_result` | result, toolUseId, agentName, taskId, durationMs, sessionId |
99
+ | `followup_start` / `queued_message` | `chat:followup` | agentName, sessionId |
100
+ | `usage` (no taskId) | `chat:usage` | contextTokens, outputTokens, turnId, modelId, usage, sessionId |
99
101
  | `complete` | `chat:complete` | sessionId |
100
- | `context` | `agent:context` | agentName, systemPrompt, taskId |
101
- | `system` | `chat:system` | text |
102
- | `error` | `error` | error, agentName, taskId |
103
- | `user` (report, no taskId) | `chat:user` | text |
102
+ | `context` | `agent:context` | agentName, systemPrompt, taskId, sessionId |
103
+ | `system` | `chat:system` | text, taskId, agentName, sessionId |
104
+ | `error` | `error` | error, agentName, taskId, sessionId |
105
+ | `user` (report, no taskId) | `chat:user` | text, sessionId |
104
106
 
105
- `chat:thinking` / `chat:stream` / `chat:followup` / `agent:tool_call` / `agent:tool_result` additionally carry `replay: true` when synthesized by the reattach path (never on live events) — see [Reconnect flow](#reconnect-flow) step 6.
107
+ Every provider streams (Bedrock, the Anthropic-Messages providers anthropic / mimo / minimax / qwen, the OpenAI-family openai / deepseek / kimi / zhipu / doubao / hunyuan, and Mantle aws-bedrock-mantle / aws-bedrock-openai): they emit `stream_delta` / `thinking_delta` per chunk *during* the model call, and the whole `stream` / `thinking` that follows the call is stamped `streamed: true` — the wire frame is the same `chat:stream` / `chat:thinking` either way (the admin appends by `turnId`), so the whole event is dropped to avoid rendering the text twice. A provider that never calls `onDelta` would still send the whole event only, exactly as before streaming existed.
108
+
109
+ `sessionId` is the session the emitting listener is attached to (`client.sessionId` at registration; `null` when unknown). The listener stays bound to that session until the client re-subscribes, so after a session switch the old turn's frames keep arriving until it completes — the admin (`isForCurrentSession` in `chat-handlers.ts`) drops any frame whose `sessionId` is set and differs from the loaded session. Frames without one (`compact:*`, `session:compacted`, the detached-buffer replays, handler-local sends) pass through. `chat:complete`, `state:snapshot` and `session:*` are never filtered.
110
+
111
+ `chat:thinking` / `chat:stream` / `chat:followup` / `agent:tool_call` / `agent:tool_result` additionally carry `replay: true` (plus `sessionId`) when synthesized by the reattach path (never on live events) — see [Reconnect flow](#reconnect-flow) step 6.
106
112
 
107
113
  `chat:system` producers (`session-manager.ts`'s `stop` event handling): a `max_tokens` stop emits `⚠️ [<agent>] Response truncated: output token limit reached.`; a `refusal` stop (Anthropic `stop_reason: "refusal"`, HTTP 200 — the model declined, not an error) emits `⚠️ [<agent>] Model declined to respond (<category>): <explanation> — …` suggesting `/new` (see [session.md](session.md#resilient-execution-loop)).
108
114
 
109
- Server-internal flags on `AgentSessionEvent` that are **not** carried into the WS frame: `stream.final` (marks the turn's wrap-up text vs. pre-tool filler — consumed by channel responders and the cli, see [session.md](session.md#message-queue-and-drain)) and `complete.batchBoundary`. The admin renders every streamed block, so neither is needed on the wire.
115
+ Server-internal flags on `AgentSessionEvent` that are **not** carried into the WS frame: `stream.final` (marks the turn's wrap-up text vs. pre-tool filler — consumed by channel responders and the cli, see [session.md](session.md#message-queue-and-drain)), `stream.streamed` / `thinking.streamed` (the text already went out as deltas — decides whether the whole event is forwarded at all) and `complete.batchBoundary`. The admin renders every streamed block, so none is needed on the wire.
110
116
 
111
117
  ### Other Server → Client messages
112
118
 
@@ -121,14 +127,15 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
121
127
  | `terminal:ready` / `terminal:output` / `terminal:exit` / `terminal:reattached` | TerminalManager | PTY output |
122
128
  | `session:changed` | `SessionManager` (broadcast to all clients) | Root session list changed — re-fetch. Fires on root-session create *and* on each root turn `complete` (so channel-driven messages refresh the count/title/ordering, not just admin's own turns). |
123
129
  | `session:switched` | handler.ts (this client only) | The server rebound this connection to a different session — see [switchTo rebind](#switchto-rebind--sessionswitched) |
124
- | `goal:changed` | `writeGoalState` (`agents/goal-mode.ts`, broadcast to all clients) | Goal-mode state transition: `{goalSessionId, workerSessionId, status, round, maxRounds}`. Emitted on every goal state write (every transition routes through `writeGoalState`, so the push can never be forgotten) plus the goal-session-delete dissolve path. **No workspace marker** — the admin re-fetches through `GET /api/sessions/goal` under its active project, which naturally filters cross-workspace events. Drives the goal banner, worker input lock, and 🎯 badge refresh. See [goal-mode.md](goal-mode.md#admin-surface--ws). |
130
+ | `goal:changed` | `writeGoalState` (`agents/goal-mode.ts`, broadcast to all clients) | Retained legacy event: `{goalSessionId, workerSessionId, status, round, maxRounds}`, no workspace marker. Emitted on goal state writes for existing goal bindings, unchanged. |
125
131
  | `session:cleared` | session:clear handler (this client only) | /session new complete — sent **after** `saveSession`, so the admin bumps its session-list bus on this event instead of guessing with a `setTimeout` |
126
132
  | `session:deleted` | session:delete handler (this client only) | Session delete complete — `{sessionId}` |
127
133
  | `chat:stopped` | `chat:stop` / `chat:interrupt` handlers (this client only) | Stop/interrupt acknowledged — `{sessionId}` |
128
134
  | `session:compacted` | compact handler | Compaction complete |
129
- | `compact:started` / `compact:summarizing` / `compact:done` | compact handler | Compaction progress |
135
+ | `compact:started` / `compact:summarizing` / `compact:done` | compact handler; `ws/event-processor.ts` | Compaction progress. An auto-compact whose LLM summary failed falls back to a local compact and closes with a `system` event carrying `compactEnd`, which also emits `compact:done` (the success path closes via `compacted`). |
130
136
  | `cron:job_changed` / `cron:run_changed` | `cron/runner.ts` + `routes/cron.ts` (broadcast) | Cron job/run state changed — the Cron tab re-fetches instead of polling. See [cron.md](cron.md). |
131
137
  | `evolution:run_changed` / `evolution:apply_changed` | `evolution/ticker.ts` + `routes/evolution.ts` (broadcast) | Evolution run/apply state changed — drives the Evolution tab. See [evolution.md](evolution.md). |
138
+ | `extension:changed` | `extensions/watcher.ts` `rescanAndBroadcast` (broadcast to all clients) | `~/.halo/global/extensions/` changed (install / upgrade / uninstall, from the admin upload, the `extension` skill, or a manual `mv`/`rm`) — carries the **full** `ExtensionsSnapshot` (`{extensions, errors}`), not a diff, and only fires when the snapshot key differs from the last one. The admin replaces its extension list wholesale: the preview registry re-resolves open tabs (an upgraded extension remounts, or shows a banner if the tab is dirty; an uninstalled one falls back to the "no built-in preview" page) and the Settings → Extensions panel re-renders. Global, no workspace marker — extensions are server-wide. See [canvas-extensions.md](canvas-extensions.md). |
132
139
 
133
140
  ### `error` frames: optional `code`
134
141
 
@@ -182,9 +189,9 @@ reattach lifecycle is unchanged — this only affects command routing.
182
189
 
183
190
  ### switchTo rebind & `session:switched`
184
191
 
185
- When a command result carries `switchTo` (e.g. `/new`, `/goal create`, `/goal resume`), the WS handler doesn't just report the new id — it **rebinds this client's event stream**: unsubscribe the old listener, set `client.sessionId`, `registerEventListener` on the target, then send `session:switched {sessionId}`. Without the rebind, streaming events from the switched-to session (e.g. the goal agent's intake greeting right after `/goal create`) would never reach the connection — the listener would still point at the old id.
192
+ When a command result carries `switchTo` (e.g. `/new`), the WS handler doesn't just report the new id — it **rebinds this client's event stream**: unsubscribe the old listener, set `client.sessionId`, `registerEventListener` on the target, then send `session:switched {sessionId}`. Without the rebind, streaming events from the switched-to session would never reach the connection — the listener would still point at the old id.
186
193
 
187
- The same mechanics run on the **goal-routing overlay** in `handleChat`: a chat aimed at a goal-bound worker is diverted to its goal session (`resolveGoalRoute`), the listener is rebound, and `session:switched` is sent. On receipt the admin clears its chat store, sets the new session id, and **re-subscribes** to pull the disk-seeded snapshot so the target session's existing transcript renders (`chat-handlers.ts`).
194
+ The same mechanics run when `handleChat` diverts a chat to a different session than the one the client is bound to (the retained legacy goal routing overlay, which still applies to any existing goal binding): the listener is rebound and `session:switched` is sent. On receipt the admin clears its chat store, sets the new session id, and **re-subscribes** to pull the disk-seeded snapshot so the target session's existing transcript renders (`chat-handlers.ts`).
188
195
 
189
196
  ### Message flow
190
197
 
@@ -215,7 +222,7 @@ Client reconnects and sends `subscribe`:
215
222
  5. Re-attach the event listener — live streaming continues seamlessly
216
223
  6. If the session is still running, synthesizes the in-progress turn as an **authoritative replay**: every synthesized message carries `replay: true` — a `chat:followup`, then the turn's `turnContentBlocks` in order (`chat:thinking` / `chat:stream` / `agent:tool_call` + `agent:tool_result` if completed), each with the session's real `agentName` and the block's real `turnId` so usage badges / turn grouping survive the reconnect. The running-session snapshot in step 3 sends the settled `messageLog` only (no `createSaveSnapshot` temp in-flight message — the replay carries that turn instead).
217
224
 
218
- **Client replace semantics** (`chat-handlers.ts` + `state-handlers.ts`): the client stashes every reattach snapshot (even when the in-flight guard skips the visible replace). On a replay-flagged `chat:followup` it **discards its locally-held in-flight turn** — resetting `messages` to the stashed settled log — and rebuilds the turn from the replayed events. Without the replace, a client that had kept its streamed text through the drop (the snapshot guard assumes it did) got the same text appended again by the replay; `toolUseId` dedup only covered tool rows. Live (non-replay) followups keep append behavior. Replayed tool entries still carry `toolUseId` (dedup + `tool_result` pairing by id, with a first-pending positional fallback for entries without one — old persisted sessions).
225
+ **Client replace semantics** (`chat-handlers.ts` + `state-handlers.ts`): the client stashes every reattach snapshot (even when the in-flight guard skips the visible replace). On a replay-flagged `chat:followup` it **discards its locally-held in-flight turn** — resetting `messages` to the stashed settled log — and rebuilds the turn from the replayed events. Without the replace, a client that had kept its streamed text through the drop (the snapshot guard assumes it did) got the same text appended again by the replay; `toolUseId` dedup only covered tool rows. Live (non-replay) followups keep append behavior. `setMessages` also moves the store's `roundStart` cursor to the end of the loaded log, so `<<<SHOW>>>` / `<<<CAPTURE>>>` markers in loaded history (snapshot or the settled log of a replay) never fire — only bubbles that arrive after it and settle at `chat:complete` do. Replayed tool entries still carry `toolUseId` (dedup + `tool_result` pairing by id, with a first-pending positional fallback for entries without one — old persisted sessions).
219
226
 
220
227
  ### Double-subscribe guard
221
228
 
@@ -19,18 +19,18 @@ Total effort: typically a few hours depending on how weird the upstream API is a
19
19
  | Provider ID | Class | File | Notes |
20
20
  |---|---|---|---|
21
21
  | `aws-bedrock-claude-invoke` | `BedrockAgent` | `bedrock-agent.ts` | Anthropic Messages API via AWS SDK |
22
- | `kimi` | `KimiAgent` | `kimi-agent.ts` | OpenAI-compatible, thinking (reasoning_content). Two API generations, branched on modelId: **K3** — vision (base64 data URLs only, no public-URL input), thinking always-on server-side, sole knob is top-level `reasoning_effort` (enum currently just `"max"`); **K2.6** — no vision, no `reasoning_effort` field at all, only the `thinking:{type:'enabled'|'disabled'}` toggle. Never send the K2.x `thinking` parameter to K3 |
23
- | `deepseek` | `DeepSeekAgent` | `deepseek-agent.ts` | OpenAI-compatible, thinking (reasoning_content). Vision per model: `deepseek-flash` (V4.1-Flash) sees `image_url` data URLs; `deepseek-v4-pro` accepts the block but ignores it (live-probed 2026-09-24 with a 64×64 blue PNG) — registry `image` flag gates it |
24
- | `minimax` | `MiniMaxAgent` | `minimax-agent.ts` | Anthropic Messages API (x-api-key). Two thinking schemes, branched on registry `thinking.mode`: **M3** (default) — vision, thinking off by default and enabled via `thinking:{type:'adaptive'}` (no levels, no budget_tokens), no `promptCaching` in the manifest on purpose (official docs scope `cache_control` to M2.x only; M3 has passive automatic caching); **M2.7** — no vision, thinking always-on (manual budget_tokens), prompt caching (5m, M2.7 doesn't report cache_creation_input_tokens) |
25
- | `qwen` | `QwenAgent` | `qwen-agent.ts` | Aliyun Bailian Anthropic-compatible API, thinking enable/disable both honored, prompt caching (5m only), vision on Plus, max no vision |
26
- | `hunyuan` | `HunyuanAgent` | `hunyuan-agent.ts` | Tencent Hy3 preview via OpenAI-compatible (tokenhub.tencentmaas.com), reasoning_effort low/high (no_think = omit), automatic prompt caching (cached_tokens), no vision |
27
- | `doubao` | `DoubaoAgent` | `doubao-agent.ts` | ByteDance Doubao Seed-2.0 (pro/lite/mini/code) on Volcengine Ark, OpenAI-compatible + `thinking:{type:'enabled'/'disabled'}` (no `auto`), automatic prompt caching, no vision |
28
- | `zhipu` | `ZhipuAgent` | `zhipu-agent.ts` | Zhipu GLM-5.x on open.bigmodel.cn, OpenAI-compatible. Output cap via `max_tokens` only (`max_completion_tokens` is silently ignored). Thinking branched on modelId: **glm-5.3\*** always thinks — always sends `thinking:{type:'enabled',clear_thinking:false}`, `reasoning_effort` only when low/high/max (`disabled` / other efforts 400); other ids (glm-5.2) honour `thinking:{type:'disabled'}`. Vision per model: `glm-5.3-flash` / `glm-5.3-flashx` see `image_url` data URLs; `glm-5.3` / `glm-5.2` are text-only and 400 on any image part (code 1210, classified `multimodal_4xx` so leftover history images degrade). Automatic caching, `prompt_tokens_details.cached_tokens` (included in `prompt_tokens`, subtracted). |
29
- | `openai` | `OpenAIAgent` | `openai-agent.ts` | **Generic OpenAI-compatible**. Use for native OpenAI or any third-party OpenAI gateway (incl. Ollama / llama.cpp). User fills endpoint + model id; capabilities openly editable in the form. Forgiving about cache field names (`prompt_tokens_details.cached_tokens`, `prompt_cache_hit_tokens`, `cache_read_tokens` all read) and reasoning field names (`reasoning_content` for OpenAI o-series/DeepSeek, `reasoning` for Ollama/llama.cpp). |
30
- | `anthropic` | `AnthropicAgent` | `anthropic-agent.ts` | **Generic Anthropic Messages**. Use for api.anthropic.com or any Anthropic-compatible gateway. User fills endpoint + model id. Standard `cache_control: ephemeral` with optional `ttl: '1h'`. |
31
- | `mimo-token-plan-china` | `AnthropicAgent` | (reuses `anthropic-agent.ts`) | Xiaomi MiMo (UI name "Xiaomi MiMo"; the id keeps its historical name so existing configs don't break) — Anthropic-compatible gateway; no dedicated class, just a default endpoint in `model-runtime.ts` (`token-plan-cn.xiaomimimo.com/anthropic`). The key type picks the endpoint: Token Plan `tp-`/`ttp-` keys only work on `token-plan-{cn,sgp,ams}.xiaomimimo.com/anthropic`, pay-as-you-go `sk-` keys only on `api.xiaomimimo.com/anthropic` — all four ship as `endpointPresets` (pay-as-you-go live-probed 2026-09-24; sgp / ams from the docs only). V2.6 (`mimo-v2.6-pro` / `-flash`, both vision, 1M / 131072) is current; `mimo-v2.5(-pro)` kept in the template until the official 2026-10-21 deprecation, then drop |
32
- | `aws-bedrock-mantle` | `MantleAgent` | `mantle-agent.ts` | OpenAI GPT-6 (Astra / Sol / Luna) and GPT-5.6 (Sol / Terra / Luna) on Amazon Bedrock via the **OpenAI Responses API** (not Completions/Converse/InvokeModel). Vision, reasoning via `reasoning.effort` (low/med/high/xhigh/max, no budget_tokens; on 5.6 `max` is accepted live but absent from the model's own validation list — measured ~6% more reasoning_tokens than xhigh; on Astra it's on the official list), `text.verbosity` (low/medium/high, same on both generations). **Region split** (live-probed 2026-09-24): us-east-1 serves GPT-6 Sol / Luna + every 5.6 model (404s Astra); us-east-2 serves only the 5.6 trio; us-west-2 serves Astra + 5.6 Terra / Luna. Template ships all three as `endpointPresets`, default is now us-east-1 (`openai.gpt-6-sol` default model); Astra agents pick us-west-2 in the form; `extractRegionFromHost` falls back to us-east-1. All GPT-6 variants: 1,050,000 context / 128,000 max output, same effort (none/low/medium/high/xhigh/max) + verbosity surface; input beyond 272K tokens bills at 2x, so cap `context.maxTokens` in agent.yaml unless that's intended. Never surfaces reasoning summaries — `output[].reasoning.summary` stays empty even with `reasoning.summary=detailed` while `reasoning_tokens` counts up (verified live), so no thinking text in the UI is upstream behavior, not a parsing gap. |
33
- | `aws-bedrock-openai` | `MantleAgent` | (reuses `mantle-agent.ts`) | Frontier models on Bedrock via the **bedrock-runtime** host's OpenAI Responses surface (`https://bedrock-runtime.<region>.amazonaws.com/openai/v1` — AWS's recommended endpoint for new apps; Mantle is the compatibility one). Same wire format, so only a second `case` in `model-runtime.ts` + `extractRegionFromHost` accepting both hostnames (SigV4 signs with the host's region — a mismatched region 401s "Credential should be scoped to a valid region"). Rules that differ from Mantle: model ids must be cross-Region inference profiles (`global.*` / `us.*`; bare ids 400 "on-demand throughput isn't supported"); **no region split** — live-probed 2026-09-24, all eight `global.*` ids answer 200 from us-east-1 / us-west-2 / eu-west-1 / ap-northeast-1 alike; **no server-side tools** — `tools:[{type:"web_search"}]` 400s "web search is not supported for this request", which is why the web-search skill's deep gear stays on Mantle. Ships the same six **GPT-6 / 5.6** entries as the Mantle template (`global.openai.gpt-6-{astra,sol,luna}`, `global.openai.gpt-5.6-{sol,terra,luna}`; identical effort none/low/medium/high/xhigh/max + verbosity surface per each model's own validation error — except Astra 400s on `none`, which the runtime never sends), plus **Grok 4.6** (500K ctx, 2x input price past 200K, max output undisclosed → 64000 carry-over, effort **only** low/medium/high/xhigh — `none`/`max` 400, so no Max preset; vision OK; $2/$6) and **Kimi K3** (1M ctx / 131072 out, effort none…max all accepted, ladder advisory on short prompts; vision OK; $3/$15). All: implicit prompt caching verified (run 1 `cache_write_tokens`, runs 2+ `cached_tokens` ≈ prefix), no reasoning summaries. |
22
+ | `kimi` | `KimiAgent` | `kimi-agent.ts` | OpenAI-compatible, streaming via `fetchChatCompletionStream` (usage arrives in its own `choices: []` chunk), thinking (reasoning_content). Two API generations, branched on modelId: **K3** — vision (base64 data URLs only, no public-URL input), thinking always-on server-side, sole knob is top-level `reasoning_effort` (enum currently just `"max"`); **K2.6** — no vision, no `reasoning_effort` field at all, only the `thinking:{type:'enabled'|'disabled'}` toggle. Never send the K2.x `thinking` parameter to K3 |
23
+ | `deepseek` | `DeepSeekAgent` | `deepseek-agent.ts` | OpenAI-compatible, streaming via `fetchChatCompletionStream` (first chunk has `content: null` + `reasoning_content: ''`; the usage chunk still carries `choices`), thinking (reasoning_content). Vision per model: `deepseek-flash` (V4.1-Flash) sees `image_url` data URLs; `deepseek-v4-pro` accepts the block but ignores it (live-probed 2026-09-24 with a 64×64 blue PNG) — registry `image` flag gates it |
24
+ | `minimax` | `MiniMaxAgent` | `minimax-agent.ts` | Anthropic Messages API (x-api-key), streaming via `fetchAnthropicStream` — note `message_start.usage` is all zeros and the real input / cache counts arrive in `message_delta.usage` (the accumulator takes them from there). Two thinking schemes, branched on registry `thinking.mode`: **M3** (default) — vision, thinking off by default and enabled via `thinking:{type:'adaptive'}` (no levels, no budget_tokens), no `promptCaching` in the manifest on purpose (official docs scope `cache_control` to M2.x only; M3 has passive automatic caching); **M2.7** — no vision, thinking always-on (manual budget_tokens), prompt caching (5m, M2.7 doesn't report cache_creation_input_tokens) |
25
+ | `qwen` | `QwenAgent` | `qwen-agent.ts` | Aliyun Bailian Anthropic-compatible API, streaming via `fetchAnthropicStream` (SSE frames come as `data:{…}` with no space after the colon — the reader accepts both), thinking enable/disable both honored, prompt caching (5m only), vision on Plus, max no vision |
26
+ | `hunyuan` | `HunyuanAgent` | `hunyuan-agent.ts` | Tencent Hy3 preview via OpenAI-compatible (tokenhub.tencentmaas.com), streaming via `fetchChatCompletionStream` (`stream_options.include_usage` accepted; usage in its own `choices: []` chunk), reasoning_effort low/high (no_think = omit), automatic prompt caching (cached_tokens), no vision |
27
+ | `doubao` | `DoubaoAgent` | `doubao-agent.ts` | ByteDance Doubao Seed-2.0 (pro/lite/mini/code) on Volcengine Ark, OpenAI-compatible, streaming via `fetchChatCompletionStream` (usage in its own `choices: []` chunk) + `thinking:{type:'enabled'/'disabled'}` (no `auto`), automatic prompt caching, no vision |
28
+ | `zhipu` | `ZhipuAgent` | `zhipu-agent.ts` | Zhipu GLM-5.x on open.bigmodel.cn, OpenAI-compatible, streaming via `fetchChatCompletionStream` (tool-call arguments arrive complete in one fragment; usage chunk carries `choices`). Output cap via `max_tokens` only (`max_completion_tokens` is silently ignored). Thinking branched on modelId: **glm-5.3\*** always thinks — always sends `thinking:{type:'enabled',clear_thinking:false}`, `reasoning_effort` only when low/high/max (`disabled` / other efforts 400); other ids (glm-5.2) honour `thinking:{type:'disabled'}`. Vision per model: `glm-5.3-flash` / `glm-5.3-flashx` see `image_url` data URLs; `glm-5.3` / `glm-5.2` are text-only and 400 on any image part (code 1210, classified `multimodal_4xx` so leftover history images degrade). Automatic caching, `prompt_tokens_details.cached_tokens` (included in `prompt_tokens`, subtracted). |
29
+ | `openai` | `OpenAIAgent` | `openai-agent.ts` | **Generic OpenAI-compatible**. Use for native OpenAI or any third-party OpenAI gateway (incl. Ollama / llama.cpp). User fills endpoint + model id; capabilities openly editable in the form. Streaming via `fetchChatCompletionStream` — always sends `stream: true` + `stream_options: { include_usage: true }` (OpenAI's own field; Ollama ≥ 0.3 / vLLM / LM Studio accept it too). A gateway that rejects unknown fields would 400 — accepted trade-off, no non-streaming fallback. Forgiving about cache field names (`prompt_tokens_details.cached_tokens`, `prompt_cache_hit_tokens`, `cache_read_tokens` all read) and reasoning field names (`reasoning_content` for OpenAI o-series/DeepSeek, `reasoning` for Ollama/llama.cpp). |
30
+ | `anthropic` | `AnthropicAgent` | `anthropic-agent.ts` | **Generic Anthropic Messages**. Use for api.anthropic.com or any Anthropic-compatible gateway. User fills endpoint + model id. Streaming via `fetchAnthropicStream` (the gateway must support `stream: true` SSE — the reference Anthropic behaviour). Standard `cache_control: ephemeral` with optional `ttl: '1h'`. |
31
+ | `mimo-token-plan-china` | `AnthropicAgent` | (reuses `anthropic-agent.ts`) | Xiaomi MiMo (UI name "Xiaomi MiMo"; the id keeps its historical name so existing configs don't break) — Anthropic-compatible gateway, streams like `anthropic` (gateway built for Claude Code, which always streams; not live-probed); no dedicated class, just a default endpoint in `model-runtime.ts` (`token-plan-cn.xiaomimimo.com/anthropic`). The key type picks the endpoint: Token Plan `tp-`/`ttp-` keys only work on `token-plan-{cn,sgp,ams}.xiaomimimo.com/anthropic`, pay-as-you-go `sk-` keys only on `api.xiaomimimo.com/anthropic` — all four ship as `endpointPresets` (pay-as-you-go live-probed 2026-09-24; sgp / ams from the docs only). V2.6 (`mimo-v2.6-pro` / `-flash`, both vision, 1M / 131072) is current; `mimo-v2.5(-pro)` kept in the template until the official 2026-10-21 deprecation, then drop |
32
+ | `aws-bedrock-mantle` | `MantleAgent` | `mantle-agent.ts` | OpenAI GPT-6.1 Sol, GPT-6 (Astra / Sol / Luna) and GPT-5.6 (Sol / Terra / Luna) on Amazon Bedrock via the **OpenAI Responses API** (not Completions/Converse/InvokeModel). Vision, reasoning via `reasoning.effort` (low/med/high/xhigh/max, no budget_tokens; on 5.6 `max` is accepted live but absent from the model's own validation list — measured ~6% more reasoning_tokens than xhigh; on Astra it's on the official list), `text.verbosity` (low/medium/high, same on both generations). **Region split** (live-probed 2026-09-24): us-east-1 serves GPT-6.1 Sol + GPT-6 Sol / Luna + every 5.6 model (404s Astra; GPT-6.1 Sol probed 2026-09-30, us-east-1 only); us-east-2 serves only the 5.6 trio; us-west-2 serves Astra + 5.6 Terra / Luna. Template ships all three as `endpointPresets`, default is now us-east-1 (`openai.gpt-6-sol` default model); Astra agents pick us-west-2 in the form; `extractRegionFromHost` falls back to us-east-1. All GPT-6 variants: 1,050,000 context / 128,000 max output, same effort (none/low/medium/high/xhigh/max) + verbosity surface; input beyond 272K tokens bills at 2x, so cap `context.maxTokens` in agent.yaml unless that's intended. Never surfaces reasoning summaries — `output[].reasoning.summary` stays empty even with `reasoning.summary=detailed` while `reasoning_tokens` counts up (verified live), so no thinking text in the UI is upstream behavior, not a parsing gap. Streams (`stream: true`, Responses API SSE folded by `readResponsesStream` in the same file): `response.output_text.delta` frames go out live, the terminal `response.completed` / `response.incomplete` frame carries the full final response object; this host sends **no** `data: [DONE]` (live-probed 2026-09-29). |
33
+ | `aws-bedrock-openai` | `MantleAgent` | (reuses `mantle-agent.ts`) | Frontier models on Bedrock via the **bedrock-runtime** host's OpenAI Responses surface (`https://bedrock-runtime.<region>.amazonaws.com/openai/v1` — AWS's recommended endpoint for new apps; Mantle is the compatibility one). Same wire format, so only a second `case` in `model-runtime.ts` + `extractRegionFromHost` accepting both hostnames (SigV4 signs with the host's region — a mismatched region 401s "Credential should be scoped to a valid region"). Rules that differ from Mantle: model ids must be cross-Region inference profiles (`global.*` / `us.*`; bare ids 400 "on-demand throughput isn't supported"); **no region split** — live-probed 2026-09-24, all nine `global.*` ids answer 200 from us-east-1 / us-west-2 / eu-west-1 / ap-northeast-1 alike; **no server-side tools** — `tools:[{type:"web_search"}]` 400s "web search is not supported for this request", which is why the web-search skill's deep gear stays on Mantle. Ships the same seven **GPT-6.1 / 6 / 5.6** entries as the Mantle template (`global.openai.gpt-6.1-sol`, `global.openai.gpt-6-{astra,sol,luna}`, `global.openai.gpt-5.6-{sol,terra,luna}`; identical effort none/low/medium/high/xhigh/max + verbosity surface per each model's own validation error — except Astra and GPT-6.1 Sol 400 on `none`, which the runtime never sends), plus **Grok 4.6** (500K ctx, 2x input price past 200K, max output undisclosed → 64000 carry-over, effort **only** low/medium/high/xhigh — `none`/`max` 400, so no Max preset; vision OK; $2/$6) and **Kimi K3** (1M ctx / 131072 out, effort none…max all accepted, ladder advisory on short prompts; vision OK; $3/$15). All: implicit prompt caching verified (run 1 `cache_write_tokens`, runs 2+ `cached_tokens` ≈ prefix), no reasoning summaries. Streams like Mantle (same event vocabulary) but this host **does** append a trailing `data: [DONE]`, which `readSseJson` skips; grok-4.6 emits a `reasoning` output item (no summary text deltas) before the `message` item. |
34
34
 
35
35
  ---
36
36
 
@@ -57,7 +57,7 @@ export interface ModelRuntime {
57
57
  }
58
58
  ```
59
59
 
60
- `AgentLoop` handles the tool execution loop (call model → execute tools → loop). You only implement `callModel()` — the provider-specific API call. **All providers use non-streaming (invoke) mode** — `callModel()` returns a `Promise<ModelCallResult>` containing the complete response. The base class `run()` method yields events per loop iteration.
60
+ `AgentLoop` handles the tool execution loop (call model → execute tools → loop). You only implement `callModel(signal, onDelta?)` — the provider-specific API call. It always returns a `Promise<ModelCallResult>` containing the complete response; the base class `run()` method yields events per loop iteration. **Streaming is optional and per-provider**: if the upstream API streams, call `onDelta({ type: 'text_delta' | 'thinking_delta', text })` per chunk as it arrives — the loop yields those as `text_delta` / `thinking_delta` events during the call and re-arms the per-call timeout on each (so it becomes an idle timeout) — then still return the whole result. A non-streaming provider would simply ignore `onDelta`; today every built-in provider streams — Bedrock, the Anthropic-Messages providers (anthropic / mimo / minimax / qwen), the OpenAI-family (openai / deepseek / kimi / zhipu / doubao / hunyuan) and Mantle (aws-bedrock-mantle / aws-bedrock-openai). The fold from Anthropic stream events into a `ModelCallResult` lives in `anthropic-stream.ts` (`AnthropicStreamAccumulator`); for an HTTP gateway speaking Anthropic Messages, `fetchAnthropicStream({ url, headers, body, signal, onDelta, tag })` in the same file is the whole `callModel` body — it posts with `stream: true`, parses the SSE with the generic `readSseJson` reader in `sse.ts` (also skips the OpenAI `[DONE]` terminator), maps a mid-stream `event: error` frame onto the `[tag] <status> <type>: <message>` error shape `classifyModelError` parses, and re-throws `AbortError` if the body ended at the abort instant. For an OpenAI-compatible `chat/completions` endpoint, `fetchChatCompletionStream({ url, headers, body, signal, onDelta, tag })` in `openai-chat-stream.ts` is the transport half of `callModel` — it posts with `stream: true` + `stream_options: { include_usage: true }`, folds the `chat.completion.chunk` frames (text / `reasoning_content` (or the Ollama `reasoning` alias) appended and reported via `onDelta`; `tool_calls[]` fragments matched by `index`, the first fragment carrying `id` + `function.name`, later ones appending `function.arguments`; `finish_reason` / `usage` last-non-null-wins — the usage chunk arrives with `choices` on deepseek / zhipu and as `choices: []` on kimi / doubao / hunyuan) back into the non-streaming `choices[0].message` + `finishReason` + `usage` shape, and throws today's `[<Tag>Agent] API error <status>: <body>` on non-2xx — the agent's existing parse / usage code runs unchanged on the folded result. Mantle's Responses API needs no accumulator at all: `readResponsesStream` (module-level in `mantle-agent.ts`) reports `response.output_text.delta` / `response.reasoning_summary_text.delta` live and keeps the `response` object off the terminal `response.completed` / `response.incomplete` frame, which is the full final response in exactly the non-streaming JSON shape — the existing `output[]` / `incomplete_details` / `usage` parse runs on it unchanged; a stream that ends without a terminal frame throws the same `MantleEmptyResponse` marker as the empty-`output[]` glitch so `classifyModelError`'s `empty_response` retry covers both.
61
61
 
62
62
  Two important constraints:
63
63
 
@@ -74,10 +74,11 @@ Two important constraints:
74
74
  toolCalls: Array<{ id: string; name: string; input: unknown }>
75
75
  usage: { inputTokens; outputTokens; totalTokens; cacheReadInputTokens?; cacheWriteInputTokens? }
76
76
  durationMs?: number
77
+ ttftMs?: number // streaming providers only: time to first content chunk
77
78
  }
78
79
  ```
79
80
 
80
- The base class `run()` translates this into `AgentEvent`s (`text`, `thinking`, `tool_call`, `tool_result`, `usage`, `stop`) that the rest of Halo consumes.
81
+ The base class `run()` translates this into `AgentEvent`s (`text`, `thinking`, `tool_call`, `tool_result`, `usage`, `stop`; plus `text_delta` / `thinking_delta` during the call for streaming providers) that the rest of Halo consumes.
81
82
 
82
83
  ### Usage reporting convention
83
84
 
@@ -114,13 +115,15 @@ import type { ContentBlock, ModelCallResult, ToolDef } from './agent-loop.js'
114
115
  export class MyProviderAgent extends AgentLoop {
115
116
  constructor(config: MyConfig) { super(config.tools); ... }
116
117
 
117
- protected async callModel(signal): Promise<ModelCallResult> {
118
+ protected async callModel(signal, onDelta?): Promise<ModelCallResult> {
118
119
  const startTime = Date.now()
119
120
  const messages = this.buildMessages() // translate AnthropicMessage[] → provider format
120
121
  const tools = this.buildTools() // translate ToolDef[] → provider format
121
122
 
122
- // POST to endpoint with stream: false, parse JSON response
123
- // Return ModelCallResult with assistantBlocks, stopReason, text, thinking, toolCalls, usage, durationMs
123
+ // call fetchChatCompletionStream({ url, headers, body, signal, onDelta, tag }) from
124
+ // openai-chat-stream.ts (see kimi-agent.ts) — it POSTs with stream: true, reports
125
+ // text / reasoning chunks through onDelta and returns the folded { message, finishReason, usage, ttftMs }
126
+ // Return ModelCallResult with assistantBlocks, stopReason, text, thinking, toolCalls, usage, durationMs, ttftMs
124
127
  }
125
128
 
126
129
  private buildMessages() { ... } // system + user/assistant/tool message conversion
@@ -375,7 +378,7 @@ The `MantleEmptyResponse` retry guard in session-manager is unrelated to this an
375
378
 
376
379
  Keeping it a hard-coded switch is deliberate for now:
377
380
  - Each provider has enough quirks (auth, response format, tool calling conventions, thinking replay rules) that a generic "provider interface with fetch client" would leak abstractions
378
- - Each provider independently extends `AgentLoop` — no shared OpenAI-compatible base class, to avoid coupling when APIs diverge
381
+ - Each provider independently extends `AgentLoop` — no shared OpenAI-compatible base class, to avoid coupling when APIs diverge; the shared piece is a transport helper function (`fetchChatCompletionStream`, like `fetchAnthropicStream`), not a base class — each agent still owns its body / message conversion / usage mapping
379
382
  - The Agent management UI reads the manifest directly; a runtime-registered plugin would need to notify the frontend dynamically
380
383
  - One provider lands every few months, not every week
381
384
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  All REST endpoints are served by Hono on port 9527 at `/api/`.
4
4
 
5
- Auth: most `/api/*` routes require a valid JWT cookie (`halo_token`). Exceptions in `PUBLIC_PATHS` (`middleware/auth.ts`) bypass the cookie: `/api/auth/login|check|logout` (but **not** `/api/auth/change-password`), the web-channel routes (`/api/web/chat|sessions|stop|history|subscribe|file`), `/api/show/state|session`, and `/api/metrics` — these are unauthenticated or use a web-channel `x-token` instead.
5
+ Auth: most `/api/*` routes require a valid JWT cookie (`halo_token`). Exceptions in `PUBLIC_PATHS` (`middleware/auth.ts`) bypass the cookie: `/api/auth/login|check|logout` (but **not** `/api/auth/change-password`), the web-channel routes (`/api/web/chat|sessions|stop|history|subscribe|file`), `/api/show/state|session`, and `/api/metrics` — these are unauthenticated or use a web-channel `x-token` instead. One more shape bypasses the cookie: `/api/extensions/<id>/<version>/<token>/<asset>` (`EXTENSION_ASSET_PATH`), where the route verifies a scoped asset token carried in the path — see [Canvas Preview Extensions](#canvas-preview-extensions).
6
6
 
7
7
  **Trust model.** The admin cookie is a single-tenant, all-or-nothing credential: there is one password (`~/.halo/secrets/config.yaml`), one JWT secret, and no roles. A valid `halo_token` equals full control of the server process — every workspace's files (`/api/files/*` project-scoped, `/api/fs/browse` unrestricted), shell via the WS terminal, channel credentials, git credentials, cron jobs, and the ability to mint web-channel tokens at any access level. Routes under the cookie therefore don't re-check "who" beyond the cookie itself; per-user isolation is the job of channel accounts (`x-token` with `readonly` / `workspace` / `full`), not of the admin. Consequences worth knowing when adding routes: (1) never put a cookie-gated route in `PUBLIC_PATHS` to "make it easier for a client" — that's the entire boundary; (2) a route that accepts a path or id from the client still needs traversal / shape checks (`isSafeIdSegment`, `assertPathAllowed`), because the *web-channel* surface reuses the same helpers and is multi-user; (3) if multi-user admin ever lands, the first change is a role claim in the JWT plus per-route checks, and this paragraph becomes the checklist.
8
8
 
@@ -22,7 +22,7 @@ File: `packages/server/src/middleware/auth.ts`
22
22
  |---|---|---|
23
23
  | POST | `/api/auth/login` | Password login, returns a JWT cookie |
24
24
  | POST | `/api/auth/logout` | Clears the auth cookie (Set-Cookie `Max-Age=0`). In `PUBLIC_PATHS`. No server-side token blacklist — a copied JWT stays valid until natural expiry |
25
- | GET | `/api/auth/check` | Validates the current token; refreshes stale tokens. Also returns `badge` (the `HALO_BADGE` env, `null` when unset) on both the 200 and the 401 so the login page can brand its favicon/title too |
25
+ | GET | `/api/auth/check` | Validates the current token; refreshes stale tokens. Also returns `badge` (the `HALO_BADGE` env, `null` when unset) on both the 200 and the 401 so the login page can brand its favicon/title too; the 200 additionally carries a boolean `goalModeEnabled` |
26
26
  | POST | `/api/auth/change-password` | Change the admin password. **Not** in `PUBLIC_PATHS` — requires a valid JWT cookie. Persists the new scrypt hash to `~/.halo/secrets/config.yaml`; takes effect immediately (no restart), does **not** rotate `jwt_secret` so existing sessions stay signed in |
27
27
 
28
28
  ## Files
@@ -37,6 +37,7 @@ File: `packages/server/src/routes/files.ts`
37
37
  | GET | `/api/files/stat?path=&projectId=` | Lightweight mtime + size |
38
38
  | GET | `/api/files/download?path=&projectId=&inline=` | Download or inline-preview — streams the file (no full read into memory), supports `Range` (206 Partial Content) so `<video>`/`<audio>` can seek + partial-load; aborts the read if the client disconnects |
39
39
  | PUT | `/api/files` | Save file (body: `{path, content, projectId}`) |
40
+ | PUT | `/api/files/raw?path=&projectId=[&expectMtime=]` | Replace an **existing** file's bytes with the raw request body (binary-safe; `PUT /api/files` is utf-8 text only). Used by canvas preview extensions' save path. 404 if the file doesn't exist (save never creates); 400 for a directory. `expectMtime` is the `modifiedAt` the client loaded from `/api/files/stat` (float ms — compared after `Math.round`): when the on-disk mtime differs → **409** `{error:'conflict', mtime, size}` so the client can offer overwrite (retry without / with the new `expectMtime`). Success → `{ok, path, mtime, size}` |
40
41
  | POST | `/api/files/new` | Create empty file |
41
42
  | POST | `/api/files/mkdir` | Create directory |
42
43
  | POST | `/api/files/rename` | Rename / move |
@@ -78,6 +79,24 @@ Pagination: `limit` defaults to 100, capped at 1000. Same `(path, projectId)` re
78
79
  - **csv** — streaming RFC 4180 tokenizer, memory O(page). Delimiter is sniffed from the header line (`,` / `;` / tab, comma wins ties); `.tsv` forces tab. `totalRows` is a lazy lower bound while `hasMore` is true (exact once the scan reaches EOF, to avoid re-reading the whole file on every page turn). A single row over 1MB is rejected with 400 (delimiter-flood / non-CSV guard).
79
80
  - All four fold cells to JSON scalars: bigints beyond ±2^53 become exact strings, BLOB/binary becomes a `<blob N bytes>` placeholder, `Date` becomes ISO text.
80
81
 
82
+ ## Canvas Preview Extensions
83
+
84
+ File: `packages/server/src/routes/extensions.ts` (+ `extensions/{registry,watcher,install}.ts`). Design: [design/canvas-extensions.md](../design/canvas-extensions.md).
85
+
86
+ Installed extensions are directories under `~/.halo/global/extensions/<id>/` with a `halo-extension.json` manifest. The server is the only validator; the admin trusts the snapshot it receives (`ExtensionsSnapshot` from `packages/core/src/protocol/extension-types.ts`).
87
+
88
+ | Method | Path | Auth | Purpose |
89
+ |---|---|---|---|
90
+ | GET | `/api/extensions` | cookie | Current snapshot `{ extensions: ExtensionInfo[], errors: ExtensionError[] }` — `errors` lists directories the scanner refused (bad / missing manifest) so the admin can show and uninstall them |
91
+ | GET | `/api/extensions/token` | cookie | Mint a scoped asset token: `{ token, expiresAt }` (JWT `{scope:'ext'}`, 24 h). The admin caches one per page and embeds it in the iframe URL |
92
+ | POST | `/api/extensions/install` | cookie | Multipart `file` = zip (≤ 100 MB, 413 above). Unpacks to a temp dir, validates the manifest (`id` must equal the directory / zip root, `entry` must exist), atomically replaces `~/.halo/global/extensions/<id>/`. Returns the resulting `ExtensionInfo`; 400 `{error}` for a bad zip / manifest |
93
+ | DELETE | `/api/extensions/:id` | cookie | Remove the directory. 400 for an invalid id shape, 404 if not installed. Returns `{ok, id}` |
94
+ | 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
+
96
+ **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.
97
+
98
+ 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`).
99
+
81
100
  ## Session Logs (unified)
82
101
 
83
102
  File: `packages/server/src/routes/sessions.ts`
@@ -91,9 +110,8 @@ Unified session log API — list + read session files across all agents.
91
110
  | GET | `/api/sessions/logs/:id/archive/:n?projectId=` | One archived UI-log segment — see [detail](#get-apisessionslogsidarchivenprojectidabs) |
92
111
  | DELETE | `/api/sessions/logs/:id?projectId=` | Delete the session log (and all of its archive segments) |
93
112
  | PATCH | `/api/sessions/logs/:id?projectId=` | Rename a session (admin-only) — updates the log file's title and the mirrored `agent_sessions.title`. Accepts any session id (root or sub-agent); the sidebar exposes the rename affordance on every row |
94
- | GET | `/api/sessions/goal?projectId=` | Latest goal binding for the workspace (goal-mode banner / input-lock seed) — see [detail](#get-apisessionsgoalprojectidabs) |
95
113
 
96
- The list endpoint returns flat metadata (id / agentId / agentName / title / timestamps / exchangeCount / parentSessionId / stoppedAt / contextTokens / totalOutputTokens / goalSessionId), served from the mirrored `agent_sessions` columns rather than by parsing each session file (rows predating the columns are backfilled from the file on first read). The frontend builds the tree from `parentSessionId`; `goalSessionId` (non-null on a goal-bound worker row) drives the 🎯 badge.
114
+ The list endpoint returns flat metadata (id / agentId / agentName / title / timestamps / exchangeCount / parentSessionId / stoppedAt / contextTokens / totalOutputTokens / goalSessionId — the last is a retained legacy field, non-null only on a goal-bound worker row; existing bindings are unchanged), served from the mirrored `agent_sessions` columns rather than by parsing each session file (rows predating the columns are backfilled from the file on first read). The frontend builds the tree from `parentSessionId`.
97
115
 
98
116
  The get endpoint returns the full session file. If only `rawMessages` is present (no event-log `messages`), `convertRawMessages()` transforms it into display format on the fly.
99
117
 
@@ -433,7 +451,7 @@ Source: [packages/server/src/middleware/auth.ts:123-134](../../../packages/serve
433
451
 
434
452
  ```json
435
453
  // 200 authenticated
436
- { "authenticated": true, "badge": "DEV" }
454
+ { "authenticated": true, "badge": "DEV", "goalModeEnabled": false }
437
455
 
438
456
  // 401 not authenticated
439
457
  { "authenticated": false, "badge": "DEV" }
@@ -445,6 +463,8 @@ can brand its favicon + title from the very first paint without a second
445
463
  endpoint or a poll. Corollary: the value is readable **without authenticating**,
446
464
  so it must stay a label, not a secret. See [env.md](./env.md).
447
465
 
466
+ `goalModeEnabled` (boolean) is present on the **200 only** — the 401 body has no such field. It is a snapshot of `config.goalModeEnabled` taken at server start (`general.goal_mode_enabled`, default `false`), so it changes only after a restart; the admin reads it on load. See [requirements/settings.md](../requirements/settings.md).
467
+
448
468
  ### POST `/api/auth/change-password`
449
469
 
450
470
  Source: [packages/server/src/middleware/auth.ts](../../../packages/server/src/middleware/auth.ts). Admin-cookie authed (not in `PUBLIC_PATHS`). Validation order: wrong current password → 401; strength (≥8 chars, at least one letter and one digit) → 400 with the specific reason; new === old → 400. When `HALO_PASSWORD` env is set it overrides the stored hash at login, so a file rewrite would silently not take effect — the endpoint rejects with 400 instead.
@@ -666,25 +686,6 @@ Failure modes, in the order checked:
666
686
 
667
687
  The `n > archiveCount` check is the point, not a formality: `archiveCount` in the active file is the archive write's commit marker, so a segment file beyond it exists only because a crash interrupted the two-step write and must never be served. See [design/session.md](../design/session.md#ui-log-archiving).
668
688
 
669
- ### GET `/api/sessions/goal?projectId=<abs>`
670
-
671
- Source: `packages/server/src/routes/sessions.ts` (`findLatestGoal` in `agents/goal-mode.ts`)
672
-
673
- Latest goal binding for the workspace — goals are serialized per workspace, so "the" goal is unambiguous. Refresh seed for the admin's goal banner / worker input lock: `goal:changed` WS pushes keep a live tab current, this endpoint restores state after a page reload. Returns `{ "goal": null }` when there is no goal or the latest one is `cleared` (a dismissed record, not a displayable state). See [design/goal-mode.md](../design/goal-mode.md).
674
-
675
- ```json
676
- // 200
677
- {
678
- "goal": {
679
- "goalSessionId": "goal_mabc123",
680
- "workerSessionId": "sid_abc",
681
- "status": "running", // intake | running | paused | halted | done
682
- "round": 3,
683
- "maxRounds": 50
684
- }
685
- }
686
- ```
687
-
688
689
  ### GET `/api/settings/schema?projectId=<abs>`
689
690
 
690
691
  Resolves declared schema (from `models/<id>.yaml` `secrets:` and `skills/<id>/config.yaml`) against current settings, returning per-field source/state and a list of orphan keys. Drives the Settings page.
@@ -1,5 +1,34 @@
1
1
  # Deployment
2
2
 
3
+ ## Local maintenance — default short path
4
+
5
+ For this personal-use workspace: **version/tag → required build → install → restart → basic version/health/startup-log checks**. For an already published version, just **install → restart → checks**; don't rebuild or republish it. Reuse applicable passed tests rather than rerunning a full suite.
6
+
7
+ No default backups, rollback preparation/scripts/instructions, or extra per-release plans/checklists/report files. A short CHANGELOG summary and a brief result are enough. Explicit authorization for the named service is sufficient; don't ask for another GO. Diagnose/fix failures within the authorized scope instead of automatic rollback.
8
+
9
+ Confirm the service/user/HOME/port in `dev-environment.local.md`. If the agent is inside the service being restarted, carry the install/restart/basic checks in one independent systemd task; from outside it (e.g. dev → prod), operate directly. No deployment framework is needed.
10
+
11
+ For this host's `/usr` installation and `ubuntu` service user, after authorization:
12
+
13
+ ```bash
14
+ (
15
+ set -e
16
+ version='x.y.z' # exact requested version
17
+ sudo sh -c 'umask 022; npm install -g --prefix /usr --no-audit --no-fund "@turmind/halo@$1"' sh "$version"
18
+ installed=$(sudo -u ubuntu env HOME=/home/ubuntu /usr/bin/halo --version 2>&1)
19
+ test "$installed" = "halo $version" # CLI writes to stderr; mismatch or launch failure stops before restart
20
+ sudo systemctl restart halo.service
21
+ curl --fail --retry 10 --retry-connrefused --retry-delay 1 --max-time 3 http://127.0.0.1:9527/api/health # status ok, requested version
22
+ sudo journalctl -u halo.service --since '2 minutes ago' -n 40 --no-pager
23
+ )
24
+ ```
25
+
26
+ **Permission gate:** root npm installs must use `umask 022`, then pass `halo --version` as the actual service user. Secrets/logs can use 077, but it must not leak into public package installation: the 1.5.0-alpha install inherited 077, made root-only package directories and left the ubuntu service failing with 203/EXEC.
27
+
28
+ **Public release is a separate requested target:** synchronize the five package versions/tag, build and publish npm once, create the GitHub release and attach the Windows exe; verify the exact npm version/dist-tag and GitHub asset. macOS dmg remains the user's step unless requested. See the [publication command reference](#public-release-command-reference) only for the needed commands.
29
+
30
+ The setup, architecture and troubleshooting material below is retained as on-demand reference, not a mandatory routine-deployment checklist.
31
+
3
32
  ## Architecture overview
4
33
 
5
34
  Halo only needs **one Node process** (Hono on port 9527 by default). API, WebSocket, and static frontend live in the same process.
@@ -196,16 +225,17 @@ This installs the `halo` binary on `$PATH`. Subcommands available:
196
225
  1. `halo upgrade` — bumps the on-disk npm package
197
226
  2. `halo server restart` — server's startup check sees `~/.halo/global/.template-version` is behind the new bundled `TEMPLATE_VERSION`, runs `ensureHaloHome` automatically, then starts. Refreshes `docs/`, built-in agents, built-in skills, system prompts, and the model registry. User-owned files (USER.md, custom agents/skills, INSTRUCTIONS.md overrides) are left alone. See `init.ts` for the per-category overwrite policy.
198
227
 
199
- ### Release checklist (npm publish → GitHub release → desktop assets)
228
+ ### Public release command reference
229
+
230
+ Use only for a new public release; the retained details here are not extra preparation for installing an existing version.
200
231
 
201
- 1. **Bump version** in the five workspace `package.json` files (`packages/{cli,server,core,admin,desktop}/package.json`) — the root `package.json` has no version field.
202
- 2. **Update `CHANGELOG.md`**: rename `[Unreleased]` → `[x.y.z] - YYYY-MM-DD`, add a fresh empty `[Unreleased]` section above it, and roll the link references at the bottom (add `[x.y.z]: compare/v<prev>...vx.y.z`, repoint `[Unreleased]` to `compare/vx.y.z...HEAD`).
203
- 3. **Bump `TEMPLATE_VERSION`** in `packages/server/src/init.ts` if any file under `templates/` was touched. `build-bundle.mjs` enforces this: it diffs `packages/server/templates` against the previous release tag and exits 1 when files changed but the number didn't move (so `pnpm bundle` and every desktop `dist:*` refuse to package a silent template edit).
204
- 4. **Build admin**: `pnpm --filter @turmind/halo-admin build` — verify `admin/out/monaco/vs/loader.js` exists.
205
- 5. Commit, tag `vx.y.z`, push, then `HALO_RELEASE=1 pnpm --filter @turmind/halo-cli bundle` and `npm publish` from `packages/cli/dist-pub/` — **exactly once**. Confirm with `npm view @turmind/halo version` (the registry replicates with a ~2–3 min lag, so wait and re-`view`; never "retry" the publish to see what happens). `dist-pub/` is only written in release mode; desktop `dist:*` builds stage into `dist-dev/` instead, so a stale or sha-suffixed bundle can't end up there — but `npm publish` is still not idempotent (v1.3.4 shipped a `1.3.4-<sha>` prerelease to npm from a second publish and it became `latest` for ~90s).
206
- 6. **GitHub release**: `gh release create vx.y.z --notes-from-tag` (or paste the CHANGELOG section).
207
- 7. **Windows exe** (the step both v1.3.0 and v1.3.1 skipped): `HALO_STAGE_FULL=1 CI=true pnpm dist:win`, then `gh release upload vx.y.z "packages/desktop/dist/Halo Setup x.y.z.exe"`. The macOS dmg is built on a Mac and uploaded the same way.
208
- 8. **Post-check** (whoever ran the release does NOT get to skip this): `gh release view vx.y.z --json assets` must list the exe, and `npm view @turmind/halo version` must print `x.y.z`. A release without the exe attached is not done.
232
+ 1. **Versions**: align the five workspace `package.json` files (`packages/{cli,server,core,admin,desktop}/package.json`) with the tag — the root `package.json` has no version field.
233
+ 2. **CHANGELOG**: a short version/date summary is enough; keep the existing `[Unreleased]` and compare-link convention without writing a separate release report.
234
+ 3. **Templates**: bump `TEMPLATE_VERSION` in `packages/server/src/init.ts` when `templates/` changed. `build-bundle.mjs` enforces this against the previous release tag; an unchanged number stops bundling.
235
+ 4. Commit/tag `vx.y.z`/push within authorization, then build the required artifacts: admin via `pnpm --filter @turmind/halo-admin build` (verify `packages/admin/out/monaco/vs/loader.js`), CLI via `HALO_RELEASE=1 pnpm --filter @turmind/halo-cli bundle`.
236
+ 5. **npm**: publish from `packages/cli/dist-pub/` **once**, using the intended dist-tag (`--tag alpha` for an alpha, not `latest`). Confirm `npm view @turmind/halo@x.y.z version` and the intended tag via `npm view @turmind/halo dist-tags.alpha` (or `dist-tags.latest` for stable); both must equal the requested version. Registry replication can lag ~2–3 minutes: wait/recheck, never republish. `dist-pub/` is release-only; desktop staging uses `dist-dev/`. Historical reason: a second v1.3.4 publish put `1.3.4-<sha>` on `latest` for ~90s.
237
+ 6. **GitHub**: `gh release create vx.y.z --notes-from-tag` (or the short CHANGELOG summary); use `--prerelease` for an alpha.
238
+ 7. **Windows exe**: `(cd packages/desktop && HALO_STAGE_FULL=1 CI=true pnpm dist:win)`, then `gh release upload vx.y.z "packages/desktop/dist/Halo Setup x.y.z.exe"`. Verify it appears in `gh release view vx.y.z --json assets`; v1.3.0 and v1.3.1 missed this asset. macOS dmg is the user's step unless requested.
209
239
 
210
240
  **npm token gotcha**: `npm publish` on this package needs a granular access token created with **"Bypass 2FA"** checked — scope / permission alone yields `403 Two-factor authentication or granular access token with bypass 2fa enabled is required`. `npm whoami` and `npm token list` succeed with a non-bypass token, so neither is a valid pre-flight; check `GET https://registry.npmjs.org/-/npm/v1/tokens` (with the token as bearer) and look for `"bypass_2fa": true` on the token in use before starting a release.
211
241
 
@@ -1,6 +1,17 @@
1
1
  # Desktop Packaging (Electron / DMG)
2
2
 
3
- How to build the desktop app. We ship two artifacts: **Windows x64**
3
+ ## Short path
4
+
5
+ Read this block and the command for the requested target; the historical Gotchas and detailed steps below are on-demand reference, not required cover-to-cover reading.
6
+
7
+ 1. Keep core/server/admin/cli/desktop versions aligned with the tag; template changes need a `TEMPLATE_VERSION` bump in `packages/server/src/init.ts`. A short CHANGELOG summary is enough.
8
+ 2. Build the target's required upstream artifacts (core → server → admin), reusing valid results for unchanged inputs. Admin uses `pnpm --filter @turmind/halo-admin build`, never bare `next build`; check `packages/admin/out/monaco/vs/loader.js` before bundling. Don't repeat passed applicable tests.
9
+ 3. Windows on Linux: `(cd packages/desktop && HALO_STAGE_FULL=1 CI=true pnpm dist:win)`. macOS dmg is the user's step unless requested; use the Mac command below when needed.
10
+ 4. For a public release, npm publication and the GitHub release must also be done, with the Windows exe uploaded and visible in `gh release view v<ver> --json assets`. For packaging-only work, report the artifact path; don't silently expand into publication or deployment.
11
+
12
+ No default backups, rollback preparation/scripts/instructions, or extra release plans/checklists/report files. Server installation/restart uses the [short maintenance path](deploy.md#local-maintenance--default-short-path), including root npm `umask 022` and a service-user `halo --version` check.
13
+
14
+ How to build the desktop app. Two artifact formats are supported: **Windows x64**
4
15
  (`Halo Setup <ver>.exe`, built on a Linux host) and **macOS arm64**
5
16
  (`Halo-<ver>-arm64.dmg`, built on Apple Silicon) — see the target matrix
6
17
  below. The desktop build is a thin Electron shell (`packages/desktop`) that
@@ -11,11 +22,10 @@ static export, then spawns the server as a child process and points a
11
22
  See [design](../design/) for the server itself; this doc is only about the
12
23
  packaging pipeline.
13
24
 
14
- > **Before packaging, check for `desktop-packaging.local.md` in this directory**
15
- > (gitignored, per-host). If present it carries operator/machine-specific setup
16
- > not captured here — e.g. building on a Linux host (wine/32-bit, workspace
17
- > build order, `corepack enable`) and artifact-distribution gotchas. Read it
18
- > first; absence just means none were recorded on this machine.
25
+ > `desktop-packaging.local.md` (gitignored, per-host), if present, carries
26
+ > machine-specific setup such as Linux wine/32-bit dependencies and artifact
27
+ > distribution. Consult the relevant target notes when needed; absence means
28
+ > none were recorded on this machine.
19
29
 
20
30
  ## Architecture
21
31
 
@@ -74,7 +84,7 @@ Output: `packages/desktop/dist/Halo-0.1.5-arm64.dmg` (~143 MB).
74
84
 
75
85
  ## Release target matrix (current policy)
76
86
 
77
- Two shipped artifacts per release, each built on its native-ish host:
87
+ Two supported targets: the release agent ships Windows by default; the user builds/uploads macOS separately unless explicitly delegated. Each uses its native-ish host:
78
88
 
79
89
  | Target | Host we build on | Command | Notes |
80
90
  |--------|------------------|---------|-------|
@@ -112,7 +122,7 @@ Verified on the v0.1.9 build (Linux x86_64 host → win-x64):
112
122
  skipped` (no Windows code-sign cert). Neither fails the build.
113
123
 
114
124
  > A `desktop-packaging.local.md` (gitignored, per-host) may carry extra
115
- > machine-specific setup — read it first if present.
125
+ > machine-specific setup — consult the relevant target section when needed.
116
126
 
117
127
  ## Faster rebuilds
118
128
 
@@ -84,7 +84,7 @@ Three config file types, precedence **env vars > config.yaml / settings.yaml > c
84
84
 
85
85
  `init.ts` seeds these on first run with a per-category policy. Refresh trigger: `halo setup` always re-runs the seed, and the server's startup check re-runs it automatically when `~/.halo/global/.template-version` is behind the bundled `TEMPLATE_VERSION` (see `init.ts:TEMPLATE_VERSION` + `index.ts` startup block).
86
86
 
87
- - **Always overwritten** (platform-owned, refreshed when the template version moves): `~/.halo/global/{prompts,models,docs}/`, `INSTRUCTIONS.md`, the built-in agent ids (`default`, `executor`, `deep-executor`, `goal`, `__evo_agent__`, `__score__`, `__apply_agent__`), built-in skill ids (`agent`, `skill`, `workspace`, `cron`, `acp`, `send-file`, `self`, `aws-knowledge`, `nova-web-search`, `halo`).
87
+ - **Always overwritten** (platform-owned, refreshed when the template version moves): `~/.halo/global/{prompts,models,docs}/`, `INSTRUCTIONS.md`, the built-in agent ids (`default`, `executor`, `deep-executor`, `__evo_agent__`, `__score__`, `__apply_agent__`), built-in skill ids (`agent`, `skill`, `workspace`, `cron`, `acp`, `send-file`, `self`, `aws-knowledge`, `nova-web-search`, `halo`).
88
88
  - **Built-in agents** keep the user's `model:` block on overwrite — the admin UI lets users change which model an agent uses, and that choice survives upgrades.
89
89
  - **Optional skills** (`tavily-web-search`) install only when picked via `halo setup`; the opt-in list is `~/.halo/global/.installed-optional-skills`. Picked skills are force-overwritten alongside the always-overwritten set.
90
90
  - **`secrets/config.yaml`** is leaf-merged: existing leaf `value`s preserved, new leaves added when a server upgrade introduces them.
@@ -103,7 +103,7 @@ Source: `packages/server/src/config.ts`
103
103
  | `HALO_MAX_CONTEXT_TOKENS` | `200000` | — | Model max context |
104
104
  | `HALO_SHELL_TIMEOUT` | `120000` | `config.yaml timeout.shell_exec` | Shell command timeout (ms) |
105
105
  | `HALO_WEB_FETCH_TIMEOUT` | `10000` | `config.yaml timeout.web_fetch` | web_fetch timeout |
106
- | `HALO_MODEL_TIMEOUT` | `1800000` | `config.yaml timeout.model_request` | Per model-call wall-clock cap (ms, 30 min). A bare fetch has no default timeout, so a half-open connection would hang the request forever; on expiry the call aborts and the agent loop retries with backoff. |
106
+ | `HALO_MODEL_TIMEOUT` | `600000` | `config.yaml timeout.model_request` | Per model-call idle timeout (ms, 10 min): every provider streams (Bedrock, the Anthropic-Messages providers anthropic / mimo / minimax / qwen, the OpenAI-family openai / deepseek / kimi / zhipu / doubao / hunyuan, and Mantle aws-bedrock-mantle / aws-bedrock-openai) and re-arms it on any data the stream delivers — text / thinking deltas, pings, tool-argument fragments, empty reasoning chunks, SSE comment frames — so it fires only when the model goes silent for this long, not on a long but live answer. It is also the only bound on a compact's LLM summary (no separate compact timeout). A bare fetch has no default timeout, so a half-open connection would hang the request forever; on expiry the call aborts and the agent loop retries with backoff. |
107
107
  | `HALO_SESSION_GRACE` | `300000` | `config.yaml timeout.session_grace` | WS-disconnect session grace |
108
108
  | `HALO_TERMINAL_GRACE` | `300000` | `config.yaml timeout.terminal_grace` | Terminal disconnect grace |
109
109
  | `HALO_MAX_CACHED_SESSIONS` | `50` | — | In-memory session cache |
@@ -122,7 +122,6 @@ settings.yaml only (no env override):
122
122
  - `general.compact.keep_messages` (default 5) — recent messages to keep uncompacted
123
123
  - `general.compact.max_summary_input` (default 15000) — local compaction fallback input cap
124
124
  - `general.compact.max_message_slice` (default 800) — local compaction per-message cap
125
- - `general.compact.summarize_timeout_sec` (default 300) — self-compact timeout
126
125
  - `general.server.trust_proxy` (default `false`) — trust `x-forwarded-for` for client IP resolution, scope: global (enable only behind a reverse proxy you control)
127
126
  - `general.sandbox.hidden_dirs` (default `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs`) — hidden from workspace/readonly sessions (bwrap tmpfs overlays on Linux, Seatbelt deny on macOS), scope: global
128
127
  - `general.sandbox.writable_dirs` (default empty) — dirs writable inside the sandbox besides the workspace (Linux / macOS), for external CLIs that keep local state (e.g. `~/.kiro`); not applied to readonly sessions, scope: global
@@ -11,7 +11,7 @@ previews/
11
11
  ├── FilePreview.tsx Public entry. Looks up plugin by extension,
12
12
  │ renders <Suspense><Component/></Suspense>.
13
13
  ├── types.ts PreviewPlugin, PreviewProps
14
- ├── registry.ts register() / getPlugin() / registeredExtensions() / isHeavyPreview()
14
+ ├── registry.ts register() / getPlugin() / registeredExtensions() / isHeavyPreview() / loadExtensions()
15
15
  ├── ui/
16
16
  │ ├── preview-shell.tsx Standard header (filename + Open-as-Text + Download + extraToolbar)
17
17
  │ ├── use-preview-fetch.ts Hook: fetch + AbortController + parse, returns {data, error, loading}
@@ -123,6 +123,14 @@ Done. The Canvas panel will:
123
123
  - Treat `.foo` / `.foobar` as non-text (routes to preview instead of Monaco)
124
124
  - Mount `FooPreview` inside an MRU cache (up to 5 concurrent plugins cached)
125
125
 
126
+ ### Routing API: `canPreview()` vs `loadExtensions()`
127
+
128
+ `canPreview(ext)` is a live read of the registry (built-ins + installed extensions), so it is only as good as the extension list loaded so far. `loadExtensions()` (re-exported from `FilePreview.tsx`) is the page's single shared initial `GET /extensions` — `workspace-layout.tsx` starts it in parallel with the workspace resolve.
129
+
130
+ - **One-shot routing decisions** (`isBinaryExtension` in `editor-panel.tsx`: click-open, tab restore after reload, open-to-side) must `await loadExtensions()` before concluding "text", because an extension may claim an extension-only type (`.glb`) or a text suffix (`.json`). A preview verdict needs no wait — extensions only add viewers ahead of text, and `FilePreview` re-picks the viewer on a registry version bump.
131
+ - The wait is capped at `INITIAL_LOAD_WAIT_MS` (3 s, `registry.ts`), counted from the first call; on timeout callers proceed as if nothing were installed. The request isn't cancelled — a late result still lands via `setExtensions`.
132
+ - It never rejects and is never re-issued: a failed fetch leaves the extension layer empty without delaying later opens. The WS-reconnect re-fetch and `extension:changed` frames fill it in.
133
+
126
134
  ## PreviewShell — the standard header
127
135
 
128
136
  Every plugin should wrap its content in `<PreviewShell>` for consistency. The shell gives you: