@turmind/halo 1.5.5 → 1.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/989-e0faeff1c4a0b0b9.js +1 -0
  3. package/admin-out/_next/static/chunks/app/page-44f707ea8dad57da.js +1 -0
  4. package/admin-out/_next/static/css/b8c87b919758c52b.css +3 -0
  5. package/admin-out/index.html +1 -1
  6. package/admin-out/index.txt +5 -5
  7. package/bundled-docs/design/agent.md +14 -12
  8. package/bundled-docs/design/architecture.md +12 -11
  9. package/bundled-docs/design/canvas-extensions.md +19 -4
  10. package/bundled-docs/design/session.md +2 -2
  11. package/bundled-docs/design/storage.md +15 -12
  12. package/bundled-docs/dev/add-model-provider.md +33 -24
  13. package/bundled-docs/dev/api.md +4 -1
  14. package/bundled-docs/dev/deploy.md +1 -1
  15. package/bundled-docs/dev/desktop-packaging.md +4 -3
  16. package/bundled-docs/dev/env.md +14 -11
  17. package/bundled-docs/dev/tools.md +2 -2
  18. package/bundled-docs/guide/agents.md +2 -2
  19. package/bundled-docs/guide/cli.md +5 -4
  20. package/bundled-docs/guide/delegation-and-access.md +1 -1
  21. package/bundled-docs/guide/getting-started.md +1 -1
  22. package/bundled-docs/guide/skills.md +3 -3
  23. package/bundled-docs/guide/workspace.md +2 -2
  24. package/bundled-docs/requirements/editor.md +3 -2
  25. package/bundled-docs/requirements/settings.md +17 -8
  26. package/dist/index.js +9671 -9631
  27. package/package.json +1 -1
  28. package/templates/agents/default/agent.yaml +1 -0
  29. package/templates/models/anthropic.yaml +1 -0
  30. package/templates/models/aws-bedrock-claude-invoke.yaml +1 -0
  31. package/templates/models/aws-bedrock-mantle.yaml +1 -0
  32. package/templates/models/aws-bedrock-openai.yaml +1 -0
  33. package/templates/models/deepseek.yaml +1 -0
  34. package/templates/models/doubao.yaml +1 -0
  35. package/templates/models/hunyuan.yaml +1 -0
  36. package/templates/models/kimi.yaml +1 -0
  37. package/templates/models/mimo-token-plan-china.yaml +1 -0
  38. package/templates/models/minimax.yaml +1 -0
  39. package/templates/models/openai.yaml +1 -0
  40. package/templates/models/qwen.yaml +1 -0
  41. package/templates/models/zhipu.yaml +1 -0
  42. package/templates/skills/extension/SKILL.md +25 -14
  43. package/templates/skills/extension/config.yaml +9 -0
  44. package/templates/skills/extension/templates/ext.sh +166 -34
  45. package/templates/skills/web-search/SKILL.md +20 -6
  46. package/templates/skills/web-search/config.yaml +13 -0
  47. package/templates/skills/web-search/search.py +27 -6
  48. package/admin-out/_next/static/chunks/989-bc743317498d3fbc.js +0 -1
  49. package/admin-out/_next/static/chunks/app/page-0a390059f1914664.js +0 -1
  50. package/admin-out/_next/static/css/b53542687ebd4c65.css +0 -3
  51. /package/admin-out/_next/static/{cuVcldfdg1PXevaQMQSzb → rTD3zkMvzwtqUwzhz6QJH}/_buildManifest.js +0 -0
  52. /package/admin-out/_next/static/{cuVcldfdg1PXevaQMQSzb → rTD3zkMvzwtqUwzhz6QJH}/_ssgManifest.js +0 -0
@@ -97,6 +97,8 @@ Protocol (single-machine scope; NFS / cross-host explicitly out):
97
97
 
98
98
  Bias is deliberate: pid reuse can read a stale pid as "alive," which only costs one missed cleanup round; a false-dead could stop another process's live sessions. Full race analysis in the file's header comment.
99
99
 
100
+ **`HALO_BADGE=DEV` servers never claim.** dev and prod serve the same workspaces, so prod (any other badge, or none) owns the lock outright: a DEV server (`config.server.ownsWorkspaceRuntimes === false`) builds its registry with `reconcileOrphansOnBoot: false`, skips the run-ledger eager sweep, and has channel route restore build the SessionManager directly. It never creates, rewrites or takes over `runtime.lock`, and runs no boot cleanup. Two costs: orphans left by a dev crash stay until prod's next restart cleans them, and a prod restart stamps the sub-sessions dev is running in a shared workspace stopped. That is what prod ownership means.
101
+
100
102
  ## Session file format (v1)
101
103
 
102
104
  Path: `.halo/sessions/{agentId}/{sessionId}.json`
@@ -342,8 +344,8 @@ general: # built-in declarer (the server itself
342
344
  max_summary_input: 15000
343
345
  max_message_slice: 800
344
346
  sandbox:
345
- hidden_dirs: "~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs"
346
- hidden_files: "~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/evo.db,~/.halo/global/evo.db-wal,~/.halo/global/evo.db-shm,~/.halo/global/cron.db,~/.halo/global/cron.db-wal,~/.halo/global/cron.db-shm,~/.halo/global/runs.db,~/.halo/global/runs.db-wal,~/.halo/global/runs.db-shm"
347
+ hidden_dirs: "~/.kube" # extras, appended to the built-in list
348
+ hidden_files: "~/.pgpass" # extras, appended to the built-in list
347
349
  logging:
348
350
  level: warn
349
351
 
@@ -385,20 +387,20 @@ A value of the form `<<ENV_NAME>>` is replaced at read time with `process.env.EN
385
387
 
386
388
  | Path | Default | Notes |
387
389
  |---|---|---|
388
- | `general.session.max_queue_size` | 256 | Max queued messages per session |
389
- | `general.session.max_nesting_depth` | 16 | Sub-session nesting cap |
390
- | `general.compact.keep_messages` | 5 | Recent messages kept intact during compaction |
391
- | `general.compact.max_summary_input` | 15000 | Local truncation fallback total char cap |
392
- | `general.compact.max_message_slice` | 800 | Local truncation per-message char cap |
390
+ | `general.session.max_queue_size` | 256 | Max queued messages per session. Restart required |
391
+ | `general.session.max_nesting_depth` | 16 | Sub-session nesting cap. Restart required |
392
+ | `general.compact.keep_messages` | 5 | Recent messages kept intact during compaction. Restart required |
393
+ | `general.compact.max_summary_input` | 15000 | Local truncation fallback total char cap. Restart required |
394
+ | `general.compact.max_message_slice` | 800 | Local truncation per-message char cap. Restart required |
393
395
  | `general.limits.shell_output_bytes` | 5242880 | Max bytes captured from one `shell_exec` (stdout+stderr); excess truncated with a `[truncated]` marker |
394
396
  | `general.limits.web_fetch_bytes` | 51200 | Max bytes downloaded by one `web_fetch` |
395
397
  | `general.limits.grep_default_matches` | 50 | Default `grep` match cap when no explicit `max` is passed |
396
398
  | `general.limits.tool_result_render_chars` | 8000 | Per-tool-result cap on the content **fed to the LLM** (truncated with a re-run marker to protect the context window / prompt cache). The UI gets a much larger slice — see `tool_result_ui_chars` and `agent-loop.ts` (`resultContent` = LLM cap, `resultTextFull` = UI cap) |
397
399
  | `general.limits.tool_result_ui_chars` | 65536 | Per-tool-result cap on the content **stored for UI display** (admin/web chat panel). Far larger than the LLM cap so a normal command's full output stays visible, but bounded so a multi-MB `cat` can't bloat the session file / WS payload / browser render; excess truncated with a marker pointing at `file_read` |
398
400
  | `general.limits.terminal_scrollback_bytes` | 50000 | Off-screen scrollback bytes retained per detached persistent terminal |
399
- | `general.sandbox.hidden_dirs` | `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs` | Hidden from workspace/readonly sessions — bwrap tmpfs overlay (Linux) / Seatbelt deny (macOS) |
400
- | `general.sandbox.hidden_files` | `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/evo.db,~/.halo/global/evo.db-wal,~/.halo/global/evo.db-shm,~/.halo/global/cron.db,~/.halo/global/cron.db-wal,~/.halo/global/cron.db-shm,~/.halo/global/runs.db,~/.halo/global/runs.db-wal,~/.halo/global/runs.db-shm` | Hidden from workspace/readonly sessions — bwrap bind of the empty `~/.halo/.sandbox-empty`, reads as empty (Linux) / Seatbelt deny (macOS) |
401
- | `general.logging.level` | warn | `debug` / `info` / `warn` / `error` |
401
+ | `general.sandbox.hidden_dirs` | `''` | Extra dirs hidden from workspace/readonly sessions — bwrap tmpfs overlay (Linux) / Seatbelt deny (macOS). **Appended** to the built-in `DEFAULT_HIDDEN_DIRS` (`tools/sandbox.ts`: `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs`), which are always included. Full sessions, the admin file explorer and the terminal are not affected |
402
+ | `general.sandbox.hidden_files` | `''` | Extra files hidden from workspace/readonly sessions — bwrap bind of the empty `~/.halo/.sandbox-empty`, reads as empty (Linux) / Seatbelt deny (macOS). **Appended** to the built-in `DEFAULT_HIDDEN_FILES` (`~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + `-wal` / `-shm`), which are always included. Full sessions, the admin file explorer and the terminal are not affected |
403
+ | `general.logging.level` | warn | `debug` / `info` / `warn` / `error`. Restart required |
402
404
  | `general.observability.endpoint` | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`); empty = off. Restart required. See [observability.md](observability.md) |
403
405
  | `general.observability.service_name` | `halo` | OTel resource `service.name`. Restart required |
404
406
  | `general.observability.headers` | `''` | **Secret.** Extra OTLP request headers, comma-separated `k=v`. Restart required |
@@ -408,7 +410,7 @@ Schema source: [packages/server/src/settings-schema.ts](../../../packages/server
408
410
 
409
411
  ### Caching
410
412
 
411
- `config.ts` reads settings.yaml lazily with mtime-watching: every read stats the file and reparses if mtime changed. UI saves bump mtime, so the server picks up new values on the next read **without a restart**.
413
+ `config.ts` reads settings.yaml lazily with mtime-watching: every read stats the file and reparses if mtime changed. UI saves bump mtime, so the server picks up new values on the next read **without a restart** — except the keys marked "Restart required" above plus `general.agent.max_retries`, which `config.ts` reads once into object literals at boot (observability: `initObservability()`); their schema fields carry `restartRequired: true` and the Settings page shows a restart notice after saving one.
412
414
 
413
415
  ### Orphans
414
416
 
@@ -423,6 +425,7 @@ Path: `~/.halo/global/models/<providerId>.yaml` — one file per provider. All f
423
425
  ```yaml
424
426
  # aws-bedrock-claude-invoke.yaml
425
427
  id: aws-bedrock-claude-invoke # required, must match filename and agent.yaml model.provider
428
+ runtime: bedrock-invoke # required, implementation class (wire protocol) — see below
426
429
  displayName: AWS Bedrock Claude (Invoke API) # shown in UI
427
430
  displayName_zh: AWS Bedrock Claude(Invoke API) # optional zh override
428
431
  description: Invokes Bedrock via InvokeModel (non-streaming)
@@ -496,7 +499,7 @@ Existing agent yaml values always win over these defaults — switching provider
496
499
 
497
500
  `agent.yaml`'s `model.provider` must match a provider's `id` — otherwise session spawn fails.
498
501
 
499
- Routing: `createModelRuntime(providerId, cfg)` → looks up the implementation by `providerId`. See [design/agent.md](../design/agent.md#agent-instance).
502
+ Routing: `id` is the provider identity (model picker, `agent.yaml` `model.provider`, `<id>.secrets.*`); `runtime:` picks the implementation class. `resolveProviderRuntime(providerId)` reads `runtime` from the registry entry, then `createModelRuntime(runtime, cfg)` dispatches on the runtime name only — the provider id is never matched against it. Runtimes: `bedrock-invoke` (BedrockAgent), `bedrock-mantle` (MantleAgent, OpenAI Responses), `anthropic-messages` (AnthropicAgent), `openai-chat` (OpenAIAgent, Chat Completions), and the vendor subclasses under their own names (`kimi`, `deepseek`, `minimax`, `qwen`, `hunyuan`, `doubao`, `zhipu`). Several providers can share one runtime (`anthropic` + `mimo-token-plan-china`; `aws-bedrock-mantle` + `aws-bedrock-openai`). No yaml for the id, no `runtime:` (a seed older than template v82), or an unknown name → session build throws naming the provider and yaml path. See [design/agent.md](../design/agent.md#agent-instance).
500
503
 
501
504
  ## Skill schema (`config.yaml`)
502
505
 
@@ -4,33 +4,37 @@ How to wire up a new LLM provider (OpenAI-compatible, a local model server — a
4
4
 
5
5
  ## Reality check
6
6
 
7
- Halo is **not a plugin system** for providers today. Adding one means:
7
+ Halo is **not a plugin system** for providers today. A provider yaml's `id` is the provider identity (model picker, `agent.yaml` `model.provider`, settings namespace `<id>.secrets.*`); its `runtime:` field names the implementation class — the wire protocol. `createModelRuntime` (in `model-runtime.ts`) switches on the **runtime name only**; the provider id is never matched against it.
8
+
9
+ A provider whose upstream speaks a protocol Halo already has (Anthropic Messages, OpenAI Chat Completions, …) is **one new YAML manifest** with `runtime:` set to that name — no code. A new wire protocol means:
8
10
  - One new runtime class extending `AgentLoop`
9
11
  - One new YAML manifest on disk
10
- - One case added to a switch statement
11
- - API key wiring in config.ts + settings.yaml
12
+ - One name added to `MODEL_RUNTIME_NAMES` + one case added to the switch in `model-runtime.ts`
13
+ - API key wiring is automatic: `secrets:` in the manifest (see step 4)
12
14
 
13
- No recompile of the frontend — the Form view's Provider dropdown reads the manifest at startup and picks up the new provider automatically. Server code does need a rebuild.
15
+ No recompile of the frontend — the Form view's Provider dropdown reads the manifest at startup and picks up the new provider automatically. Server code needs a rebuild only when you added a runtime.
14
16
 
15
17
  Total effort: typically a few hours depending on how weird the upstream API is about tool use and response format.
16
18
 
17
19
  ### Existing providers
18
20
 
19
- | Provider ID | Class | File | Notes |
20
- |---|---|---|---|
21
- | `aws-bedrock-claude-invoke` | `BedrockAgent` | `bedrock-agent.ts` | Anthropic Messages API via AWS SDK |
22
- | `kimi` | `KimiAgent` | `kimi-agent.ts` | OpenAI-compatible, streaming via `fetchChatCompletionStream` (usage arrives in its own `choices: []` chunk), thinking (reasoning_content). Cached tokens via `cachedPromptTokens` (every known usage key — which one Kimi reports isn't pinned). 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 (`cachedPromptTokens`: first value > 0 of `prompt_tokens_details.cached_tokens`, top-level `cached_tokens`, `prompt_cache_hit_tokens`, `cache_read_tokens`) 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. |
21
+ Runtime names (`runtime:` value → class): `bedrock-invoke` → `BedrockAgent`, `bedrock-mantle` → `MantleAgent` (OpenAI Responses API), `anthropic-messages` → `AnthropicAgent`, `openai-chat` → `OpenAIAgent` (Chat Completions); the vendor subclasses keep their vendor name (`kimi`, `deepseek`, `minimax`, `qwen`, `hunyuan`, `doubao`, `zhipu`). No implementation class reads the provider id — the endpoint (required in `agent.yaml`) carries everything host-specific.
22
+
23
+ | Provider ID | `runtime` | Class | File | Notes |
24
+ |---|---|---|---|---|
25
+ | `aws-bedrock-claude-invoke` | `bedrock-invoke` | `BedrockAgent` | `bedrock-agent.ts` | Anthropic Messages API via AWS SDK |
26
+ | `kimi` | `kimi` | `KimiAgent` | `kimi-agent.ts` | OpenAI-compatible, streaming via `fetchChatCompletionStream` (usage arrives in its own `choices: []` chunk), thinking (reasoning_content). Cached tokens via `cachedPromptTokens` (every known usage key — which one Kimi reports isn't pinned). 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 |
27
+ | `deepseek` | `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 |
28
+ | `minimax` | `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) |
29
+ | `qwen` | `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 |
30
+ | `hunyuan` | `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 |
31
+ | `doubao` | `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 |
32
+ | `zhipu` | `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). |
33
+ | `openai` | `openai-chat` | `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 (`cachedPromptTokens`: first value > 0 of `prompt_tokens_details.cached_tokens`, top-level `cached_tokens`, `prompt_cache_hit_tokens`, `cache_read_tokens`) and reasoning field names (`reasoning_content` for OpenAI o-series/DeepSeek, `reasoning` for Ollama/llama.cpp). |
34
+ | `anthropic` | `anthropic-messages` | `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'`. |
35
+ | `mimo-token-plan-china` | `anthropic-messages` | `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 `runtime: anthropic-messages` in its yaml (`defaultEndpoint` `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 |
36
+ | `aws-bedrock-mantle` | `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). |
37
+ | `aws-bedrock-openai` | `bedrock-mantle` | `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 its yaml just says `runtime: bedrock-mantle` + `extractRegionFromHost` accepts 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
38
 
35
39
  ---
36
40
 
@@ -155,6 +159,7 @@ Create `packages/server/templates/models/<provider>.yaml` (deployed to `~/.halo/
155
159
 
156
160
  ```yaml
157
161
  id: myprovider # required, must match filename and agent.yaml model.provider
162
+ runtime: myprovider # required, implementation class — an existing name (e.g. openai-chat) or your new one
158
163
  displayName: My Provider
159
164
  description: Short description
160
165
  defaultEndpoint: https://api.example.com/v1
@@ -190,15 +195,17 @@ Shape source: [packages/server/templates/models/aws-bedrock-claude-invoke.yaml](
190
195
 
191
196
  ### 3. Wire the dispatcher
192
197
 
193
- Edit [packages/server/src/agents/model-runtime.ts](../../../packages/server/src/agents/model-runtime.ts) — add import and switch case:
198
+ Skip this step when the manifest's `runtime:` names an existing runtime. For a new one, edit [packages/server/src/agents/model-runtime.ts](../../../packages/server/src/agents/model-runtime.ts) — add the name to `MODEL_RUNTIME_NAMES`, then the import and switch case (keyed by the runtime name, not the provider id):
194
199
 
195
200
  ```ts
196
201
  import { MyProviderAgent } from './myprovider-agent.js'
197
202
 
203
+ export const MODEL_RUNTIME_NAMES = [/* … */, 'myprovider'] as const
204
+
198
205
  case 'myprovider':
199
206
  return new MyProviderAgent({
200
207
  modelId: cfg.modelId,
201
- endpoint: cfg.endpoint ?? 'https://api.example.com/v1',
208
+ endpoint: cfg.endpoint, // required — agent.yaml model.endpoint
202
209
  apiKey: cfg.apiKey ?? '',
203
210
  systemPrompt: cfg.systemPrompt,
204
211
  tools: cfg.tools,
@@ -239,7 +246,7 @@ If a user wants to keep the key in an env var, they leave the `default` (`<<MY_P
239
246
 
240
247
  ### 5. Bump template version
241
248
 
242
- In [packages/server/src/init.ts](../../../packages/server/src/init.ts), increment `TEMPLATE_VERSION` so new manifests and settings deploy on restart.
249
+ In [packages/server/src/init.ts](../../../packages/server/src/init.ts), increment `TEMPLATE_VERSION` so new manifests and settings deploy on the next server start or `halo cli` / `halo tui` run (both call `refreshTemplatesIfOutdated`).
243
250
 
244
251
  ### 6. Use it
245
252
 
@@ -265,12 +272,14 @@ Or via the Form view: the Provider dropdown will include the new provider once t
265
272
  5. Check the usage badges: `inputTokens` (non-cached) + `cacheRead` should equal total prompt tokens; token ring should show correct context window percentage
266
273
  6. If the provider supports thinking, verify `reasoning_content` appears and tool loop works across multiple turns
267
274
 
268
- If something's wrong, server logs under `~/.halo/global/logs/server.log` are the first place to look. The dispatcher throws `Unknown provider "..."` when either the manifest's `id` doesn't match or the switch is missing a case.
275
+ If something's wrong, server logs under `~/.halo/global/logs/server.log` are the first place to look. Session build throws a `[model-runtime]` error naming the provider and its yaml path when no manifest has that `id` (`Unknown provider "..."`), when the manifest has no `runtime:` (stale seed — run `halo setup` or restart the server), or when `runtime:` names something not in `MODEL_RUNTIME_NAMES` (the error lists the valid names).
269
276
 
270
277
  ---
271
278
 
272
279
  ## When to use the generic `openai` / `anthropic` provider instead of writing a new class
273
280
 
281
+ (A curated provider of your own for a stock-protocol vendor is just a manifest with `runtime: openai-chat` or `runtime: anthropic-messages` — `mimo-token-plan-china` is that case.)
282
+
274
283
  Two of the providers above are deliberately generic — they take whatever `endpoint` + `model id` the user fills in the form and don't ship a curated model list. Use them when:
275
284
 
276
285
  - The new vendor speaks **stock OpenAI chat completions** or **stock Anthropic Messages** with no quirks worth coding around. Examples: self-hosted vLLM, third-party OpenAI gateway.
@@ -391,7 +400,7 @@ Keeping it a hard-coded switch is deliberate for now:
391
400
  - The Agent management UI reads the manifest directly; a runtime-registered plugin would need to notify the frontend dynamically
392
401
  - One provider lands every few months, not every week
393
402
 
394
- If / when Halo has 5+ providers and the switch becomes unwieldy, the next step would be a module-load registry — each provider exports a `register(providerId, factory)` call that the dispatcher discovers at boot. Until then, editing the switch is simpler than managing a registry.
403
+ The switch is keyed by runtime name (one case per wire protocol / vendor class), so providers that share a protocol never add a case. If / when the switch becomes unwieldy, the next step would be a module-load registry — each runtime exports a `register(runtimeName, factory)` call that the dispatcher discovers at boot. Until then, editing the switch is simpler than managing a registry.
395
404
 
396
405
  ---
397
406
 
@@ -558,7 +558,7 @@ Source: [packages/server/src/routes/agent-configs.ts](../../../packages/server/s
558
558
  ```
559
559
 
560
560
  Files created:
561
- - `<agentDir>/agent.yaml` — scaffold whose `model` block comes from the provider named by `general.agent.default_provider` (else `aws-bedrock-claude-invoke`, else the first installed provider), empty tools/skills
561
+ - `<agentDir>/agent.yaml` — scaffold whose `model` block is a copy of the default agent's `model:` mapping (workspace scope: `<projectId>/.halo/agents/default/agent.yaml` if it exists, else the global default agent; global scope: the global one). Missing file / no `model` mapping → derived from the provider registry (`aws-bedrock-claude-invoke` if installed, else the first installed provider). Empty tools/skills; the response's `agent.model` is that block's `id`
562
562
  - `<agentDir>/AGENT.md` — `# <name>\n\n<description>\n`
563
563
 
564
564
  ### PUT `/api/agent-configs/:id/yaml?scope=&projectId=`
@@ -735,6 +735,9 @@ Resolves declared schema (from `models/<id>.yaml` `secrets:`, `skills/<id>/confi
735
735
  "description_zh": "...",
736
736
  "default": "...", // schema-default placeholder; omitted when the schema declares none
737
737
  "secret": true, // UI masks input + value
738
+ "globalOnly": false, // general.* only: workspace overrides ignored, workspace writes 400
739
+ "restartRequired": false, // general.* only: read once at boot — UI shows a restart notice after saving
740
+ "advanced": false, // general.* only: rendered in the collapsed "Advanced" area (all but language / theme)
738
741
  "value": "AK****ST", // already masked when secret:true; null = unset
739
742
  "hasValue": true, // any layer has a non-empty value
740
743
  "source": "global", // 'workspace' | 'global' | 'unset'
@@ -224,7 +224,7 @@ This installs the `halo` binary on `$PATH`. Subcommands available:
224
224
  ### Upgrade flow
225
225
 
226
226
  1. `halo upgrade` — bumps the on-disk npm package
227
- 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.
227
+ 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 (`halo cli` / `halo tui` run the same check on start, so a CLI-only install refreshes without a server). 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.
228
228
 
229
229
  ### Public release command reference
230
230
 
@@ -374,9 +374,10 @@ Still good practice: build server + admin **before** `pnpm dist:arm64`.
374
374
 
375
375
  - **Changing anything under `templates/` requires bumping `TEMPLATE_VERSION`
376
376
  (`packages/server/src/init.ts`) — otherwise the change never reaches existing
377
- installs.** Server startup only re-runs `ensureHaloHome` (the template
378
- reseed) when the on-disk `~/.halo/global/.template-version` is *strictly less
379
- than* the compiled `TEMPLATE_VERSION` (`index.ts` startup block). Equal → the
377
+ installs.** Server startup (and `halo cli` / `halo tui` start) only re-runs
378
+ `ensureHaloHome` (the template reseed) when the on-disk
379
+ `~/.halo/global/.template-version` is *strictly less than* the compiled
380
+ `TEMPLATE_VERSION` (`init.ts` `refreshTemplatesIfOutdated`). Equal → the
380
381
  whole reseed is skipped, so a template edit with no version bump is invisible
381
382
  to every machine that already has the prior version stamped. This bit the
382
383
  `team`-delegation rewrite: `default/AGENT.md` gained the "team whitelist"
@@ -82,11 +82,12 @@ Three config file types, precedence **env vars > config.yaml / settings.yaml > c
82
82
  | `~/.halo/secrets/settings.yaml` | User | Provider secrets, skill params, `general.*` server knobs (session, compaction, limits, sandbox, logging) — "preferences" |
83
83
  | `<project>/.halo/settings.yaml` | Project | Overrides of provider / skill `params` and `secrets`; every `general.*` key is global-only (a workspace value is rejected by the API and ignored at runtime) |
84
84
 
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).
85
+ `init.ts` seeds these on first run with a per-category policy. Refresh trigger: `halo setup` always re-runs the seed, and the startup check re-runs it automatically when `~/.halo/global/.template-version` is behind the bundled `TEMPLATE_VERSION` — on server start and at the start of `halo cli` / `halo tui` (`init.ts` `refreshTemplatesIfOutdated`, called from `index.ts` and the CLI's `harness.ts` `initRuntime`; a never-seeded home is left to `halo setup`).
86
86
 
87
87
  - **Always overwritten** (platform-owned, refreshed when the template version moves): `~/.halo/global/{prompts,models,docs}/`, `INSTRUCTIONS.md`, the built-in agent ids (`default`, `executor`, `deep-executor`, `goal`, `__evo_agent__`, `__score__`, `__apply_agent__`), built-in skill ids (`agent`, `skill`, `workspace`, `cron`, `acp`, `send-file`, `aws-knowledge`, `web-search`, `self`, `halo`, `extension`).
88
88
  - **Built-in agents** keep the user's `model:` and `context:` blocks on overwrite — the admin UI lets users change which model an agent uses and its context limits, and that choice survives upgrades.
89
89
  - **Optional skills** (`tavily-web-search`) install only when picked via `halo setup`; the opt-in list is `~/.halo/global/.installed-optional-skills`. Picked skills are force-overwritten alongside the always-overwritten set.
90
+ - **Skill params in `halo setup`**: a newly picked optional skill walks all its declared params; for built-in skills setup asks only the `secret: true` ones (`listRequiredSkillsWithSecrets`, `setup-providers.ts`) — plain params such as `extension.params.hub_repo` / `web-search.params.fast_region` are set in Settings → Skills only.
90
91
  - **`secrets/config.yaml`** is leaf-merged: existing leaf `value`s preserved, new leaves added when a server upgrade introduces them.
91
92
  - **`secrets/settings.yaml`** is created empty if missing and never touched again. Defaults live in `settings-schema.ts`.
92
93
 
@@ -111,7 +112,7 @@ Source: `packages/server/src/config.ts`
111
112
  | `HALO_LOG_MAX_FILES` | `5` | `config.yaml logging.max_files` | Retained rotated logs |
112
113
  | `HALO_LOG_LEVEL` | `warn` | `settings.yaml general.logging.level` | Log level: debug \| info \| warn \| error |
113
114
  | `HALO_JWT_SECRET` | (none) | `config.yaml server.jwt_secret` | JWT signing secret (generated by `halo setup` if missing; stripped from child-process env) |
114
- | `HALO_BADGE` | (none) | — | Environment badge (e.g. `DEV`) returned by `GET /api/auth/check` on **both** the 200 and 401 branches, so the admin can brand the login screen too. The admin repaints the favicon with a badge band and prefixes the tab title, making parallel dev/prod tabs tellable apart. dev and prod serve the same static build, so this has to be a runtime signal. Unset / whitespace-only → `null` → stock branding. Because the 401 branch carries it, the value is **public to unauthenticated clients** — keep it a label (`DEV`, `staging`), never anything sensitive. |
115
+ | `HALO_BADGE` | (none) | — | Environment badge (e.g. `DEV`) returned by `GET /api/auth/check` on **both** the 200 and 401 branches, so the admin can brand the login screen too. The admin repaints the favicon with a badge band and prefixes the tab title, making parallel dev/prod tabs tellable apart. dev and prod serve the same static build, so this has to be a runtime signal. Unset / whitespace-only → `null` → stock branding. Because the 401 branch carries it, the value is **public to unauthenticated clients** — keep it a label (`DEV`, `staging`), never anything sensitive. `DEV` (trimmed, any case) also makes the server a **non-owner of workspace runtimes**: it never claims `<workspace>/.halo/runtime.lock` and skips boot cleanup (orphan reconcile, goal / run-ledger sweeps), leaving both to the prod server that shares those workspaces. Any other value → normal owner. See [storage.md](../design/storage.md#workspace-runtime-lock). |
115
116
  | `HALO_RUNTIME_MODE` | (none) | — | `agentcore` runs the server as an Amazon Bedrock AgentCore Runtime container (see [design/agentcore.md](../design/agentcore.md)) |
116
117
  | `HALO_WORKSPACE` | `process.cwd()` | — | Workspace root in agentcore mode |
117
118
  | `HALO_DEFAULT_PROVIDER` | (none) | — | Read by `halo setup --non-interactive`: binds the built-in agents to this provider id (and its default model). An unknown id only prints a warning |
@@ -120,17 +121,17 @@ Source: `packages/server/src/config.ts`
120
121
  | `HALO_PROMPT` | (none) | — | `plain` makes `halo setup` use numbered-menu prompts instead of the raw-mode TUI |
121
122
 
122
123
  settings.yaml only (no env override):
123
- - `general.session.max_queue_size` (default 256)
124
- - `general.session.max_nesting_depth` (default 16)
125
- - `general.agent.max_retries` (default 5)
126
- - `general.compact.keep_messages` (default 5) — recent messages to keep uncompacted
127
- - `general.compact.max_summary_input` (default 15000) — local compaction fallback input cap
128
- - `general.compact.max_message_slice` (default 800) — local compaction per-message cap
124
+ - `general.session.max_queue_size` (default 256), restart required
125
+ - `general.session.max_nesting_depth` (default 16), restart required
126
+ - `general.agent.max_retries` (default 5), restart required
127
+ - `general.compact.keep_messages` (default 5) — recent messages to keep uncompacted, restart required
128
+ - `general.compact.max_summary_input` (default 15000) — local compaction fallback input cap, restart required
129
+ - `general.compact.max_message_slice` (default 800) — local compaction per-message cap, restart required
129
130
  - `general.server.trust_proxy` (default `false`) — trust `x-forwarded-for` for client IP resolution, scope: global (enable only behind a reverse proxy you control)
130
- - `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
131
+ - `general.sandbox.hidden_dirs` (default empty) — extra dirs hidden from workspace/readonly sessions (bwrap tmpfs overlays on Linux, Seatbelt deny on macOS), **appended** to the built-in list, which is always included (`~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs`); Full sessions, the admin file explorer and the terminal are not affected, scope: global
131
132
  - `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
132
- - `general.sandbox.hidden_files` (default `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + their `-wal`/`-shm` files) — hidden from workspace/readonly sessions, read as empty files (bwrap bind of `~/.halo/.sandbox-empty` on Linux, Seatbelt deny on macOS), scope: global
133
- - `general.logging.level` (default `warn`) — log level: debug | info | warn | error
133
+ - `general.sandbox.hidden_files` (default empty) — extra files hidden from workspace/readonly sessions, read as empty files (bwrap bind of `~/.halo/.sandbox-empty` on Linux, Seatbelt deny on macOS), **appended** to the built-in list, which is always included (`~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + their `-wal`/`-shm` files); Full sessions, the admin file explorer and the terminal are not affected, scope: global
134
+ - `general.logging.level` (default `warn`) — log level: debug | info | warn | error, restart required
134
135
  - `general.observability.endpoint` (default `''`) — OTLP collector base URL (e.g. `http://localhost:4318`); empty = off, scope: global, restart required. See [design/observability.md](../design/observability.md)
135
136
  - `general.observability.service_name` (default `halo`) — OTel resource `service.name`, scope: global, restart required
136
137
  - `general.observability.headers` (default `''`, secret) — extra OTLP request headers, comma-separated `k=v`, scope: global, restart required
@@ -145,6 +146,8 @@ When a provider manifest declares a `default: <<NAME>>` for a secret, `halo setu
145
146
 
146
147
  `<skill-id>.params.*` (referenceable from skills via `{{<skill-id>.params.<key>}}`, or short form `{{params.<key>}}` inside SKILL.md; declared in `skills/<id>/config.yaml`):
147
148
  - `tavily-web-search.params.api_key` — example only; declared by whichever skill needs it
149
+ - `web-search.params.fast_region` / `web-search.params.deep_region` — AWS regions for web-search's fast gear (Nova grounding, US only; empty = us-east-1) and deep gear (Bedrock Mantle; empty = us-west-2 for luna, us-east-1 for sol, and sol set to a region it isn't offered in falls back to us-east-1)
150
+ - `extension.params.hub_repo` — hub `/extension install <id>` pulls from (empty = `https://github.com/turmind/halo-hub`); the skill's `ext.sh` also reads the optional `GITHUB_TOKEN` / `GITLAB_TOKEN` / `GITEA_TOKEN` from the server environment and sends them to that hub's release API only. See [design/canvas-extensions.md](../design/canvas-extensions.md#extension-skill-and-halo-hub)
148
151
 
149
152
  Hardcoded (config.ts, no override):
150
153
  - `auth.tokenMaxAge` — 14 days
@@ -175,9 +175,9 @@ This is a known, unfixed gap (not a bug in a specific route): don't hand out `wo
175
175
 
176
176
  Sensitive directories and files are hidden from workspace/readonly sessions via bwrap overlays / Seatbelt deny rules (and the same lists gate `assertPathAllowed` for in-process file tools). Under bwrap a hidden file reads as empty and a hidden dir as an empty directory; under Seatbelt both are denied outright. Paths that don't exist on the filesystem are silently skipped. Two categories coexist:
177
177
 
178
- **Global lists (configurable)** — credentials and cross-workspace state, configured in `settings.yaml` under `general.sandbox`:
178
+ **Global lists (built-in + configurable extras)** — credentials and cross-workspace state. The built-in lists are code constants in `sandbox.ts` (`DEFAULT_HIDDEN_DIRS` / `DEFAULT_HIDDEN_FILES`, the single source) and are always included in the effective lists (which apply to workspace/readonly sessions only — Full sessions skip the sandbox entirely); `settings.yaml` `general.sandbox.hidden_dirs` / `hidden_files` hold extra entries that are **appended** (deduped) — they never replace the built-ins, so a built-in entry cannot be removed from the list in settings. `config.ts` `resolveSandboxPaths()` builds the effective lists for both the server and the CLI:
179
179
 
180
- | Setting | Default | Method |
180
+ | Setting | Built-in (always included) | Method |
181
181
  |---|---|---|
182
182
  | `hidden_dirs` | `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs` | bwrap `--tmpfs` overlay (empty directory); Seatbelt `subpath` deny |
183
183
  | `hidden_files` | `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + their `-wal`/`-shm` files | bwrap `--ro-bind ~/.halo/.sandbox-empty` (reads as empty); Seatbelt `literal` deny |
@@ -227,6 +227,6 @@ With `export NANO_BANANA_KEY=sk-xxx` in the env, the agent's system prompt gets
227
227
 
228
228
  ## Model registry — adding a new provider
229
229
 
230
- Halo ships 13 providers (`aws-bedrock-claude-invoke`, `aws-bedrock-mantle`, `aws-bedrock-openai`, `anthropic`, `openai`, `kimi`, `deepseek`, `minimax`, `qwen`, `hunyuan`, `doubao`, `zhipu`, `mimo-token-plan-china`); each one is a yaml under `~/.halo/global/models/` plus a matching case in [packages/server/src/agents/model-runtime.ts](../../../packages/server/src/agents/model-runtime.ts). For any OpenAI- or Anthropic-compatible endpoint you don't need new code: pick `openai` or `anthropic` as the provider and fill in the endpoint and model id in Form view.
230
+ Halo ships 13 providers (`aws-bedrock-claude-invoke`, `aws-bedrock-mantle`, `aws-bedrock-openai`, `anthropic`, `openai`, `kimi`, `deepseek`, `minimax`, `qwen`, `hunyuan`, `doubao`, `zhipu`, `mimo-token-plan-china`); each one is a yaml under `~/.halo/global/models/` whose `runtime:` field names the implementation in [packages/server/src/agents/model-runtime.ts](../../../packages/server/src/agents/model-runtime.ts) (e.g. `anthropic-messages`, `openai-chat`, `bedrock-invoke`). For any OpenAI- or Anthropic-compatible endpoint you don't need new code: pick `openai` or `anthropic` as the provider and fill in the endpoint and model id in Form view.
231
231
 
232
- A truly new provider takes a yaml manifest plus a runtime case; the step-by-step is in [dev/add-model-provider.md](../dev/add-model-provider.md). If you drop a provider yaml without a runtime case, session spawn fails with `Unknown provider "..."`.
232
+ A provider that speaks an existing wire protocol needs only a yaml manifest with that `runtime:`; a new protocol also needs a runtime class. The step-by-step is in [dev/add-model-provider.md](../dev/add-model-provider.md). A provider yaml without `runtime:` (or with a name Halo doesn't know) makes session spawn fail with an error naming the yaml; for a bundled provider that means the seed is stale — run `halo setup` or restart the Halo server.
@@ -251,7 +251,8 @@ Session prefix: `cli_`. Sessions are persisted to `<workspace>/.halo/sessions/`
251
251
  ### Initialization
252
252
 
253
253
  `tui` / `cli` / `agents` / `sessions` refuse to run ("~/.halo/global/ not initialized. Run halo setup first.") until `halo setup` has seeded `~/.halo/global/`. Then the CLI replicates the server's init sequence (`initRuntime()` in `harness.ts`):
254
- 1. `initLogger()` — redirect console to stderr + file logger
255
- 2. `initBwrapCheck()` — probe sandbox availability
256
- 3. `setSandboxHiddenPaths()` — configure sandbox paths
257
- 4. `new SessionManager(workspace)` — create agent session manager (the workspace's `.halo/` is seeded via `ensureWorkspaceHalo`)
254
+ 1. `refreshTemplatesIfOutdated()` — the server's template startup check: when `~/.halo/global/.template-version` is behind the bundled `TEMPLATE_VERSION` (typically right after `halo upgrade`), re-seed `~/.halo/global/` before anything reads the models registry; logs `[CLI] Templates outdated (vX → vY), refreshing ~/.halo/global/` to stderr. Runs once per upgrade — afterwards the stamp matches and it's a single file read
255
+ 2. `initLogger()` — redirect console to stderr + file logger
256
+ 3. `initBwrapCheck()` — probe sandbox availability
257
+ 4. `setSandboxHiddenPaths()` — configure sandbox paths
258
+ 5. `new SessionManager(workspace)` — create agent session manager (the workspace's `.halo/` is seeded via `ensureWorkspaceHalo`)
@@ -104,7 +104,7 @@ Extra rules:
104
104
  - `view_image` also needs a vision-capable model.
105
105
  - A skill with `requiresAccess` above the session level is hidden (no metadata, no `activate_skill`).
106
106
  - Relay tools are only built for `full` sessions.
107
- - Hidden from `workspace` / `readonly` sessions: the `general.sandbox.hidden_dirs` / `hidden_files` lists (default: `~/.halo/secrets`, `~/.aws`, `~/.ssh`, `~/.gnupg`, `~/.docker`, `~/.config/gh`, `~/.gitconfig`, `~/.git-credentials`, `~/.npmrc`, `~/.netrc`, the global evo/cron/runs databases, internal-session transcripts, logs, …) plus the workspace's own `.halo/sessions`, `.halo/logs`, `.halo/evo`, `.halo/halo.db*` (fixed in code). The rest of `.halo/` stays readable.
107
+ - Hidden from `workspace` / `readonly` sessions only (Full sessions, the admin file explorer and the terminal see everything): a built-in list that is always included (`~/.halo/secrets`, `~/.aws`, `~/.ssh`, `~/.gnupg`, `~/.docker`, `~/.config/gh`, `~/.gitconfig`, `~/.git-credentials`, `~/.npmrc`, `~/.netrc`, the global evo/cron/runs databases, internal-session transcripts, logs, …), any extra paths added in `general.sandbox.hidden_dirs` / `hidden_files` (they add to the built-in list, never replace it), plus the workspace's own `.halo/sessions`, `.halo/logs`, `.halo/evo`, `.halo/halo.db*` (fixed in code). The rest of `.halo/` stays readable.
108
108
 
109
109
  ### Where a session's level comes from
110
110
 
@@ -77,7 +77,7 @@ After `halo setup`:
77
77
  └── channels/channels.db ← per-channel account state
78
78
  ```
79
79
 
80
- **When does "refreshed on upgrade" actually run?** `halo setup` always re-runs the seed; the server's startup check also re-runs it automatically when `~/.halo/global/.template-version` is behind the bundled `TEMPLATE_VERSION`. So the routine flow `halo upgrade && halo server restart` is enough — no need to remember `halo setup`.
80
+ **When does "refreshed on upgrade" actually run?** `halo setup` always re-runs the seed; the server's startup check — and the same check at the start of `halo cli` / `halo tui` — also re-runs it automatically when `~/.halo/global/.template-version` is behind the bundled `TEMPLATE_VERSION`. So the routine flow `halo upgrade && halo server restart` is enough — no need to remember `halo setup` — and a CLI-only user just runs `halo cli` / `halo tui` after `halo upgrade`.
81
81
 
82
82
  **Server-refreshed**: `INSTRUCTIONS.md`, `prompts/`, `models/`, `docs/`, the built-in agent ids (`default`, `executor`, `deep-executor`, `goal`, `__evo_agent__`, `__score__`, `__apply_agent__`), and the built-in skill ids (`agent`, `skill`, `workspace`, `cron`, `acp`, `send-file`, `self`, `aws-knowledge`, `web-search`, `halo`, `extension`). To customize one, copy it into the workspace scope (`<project>/.halo/...`) — workspace replaces global at runtime.
83
83
 
@@ -138,7 +138,7 @@ params:
138
138
  secrets: [] # rare for skills — server-side only, never substituted into shell_exec
139
139
  ```
140
140
 
141
- The Settings page reads this declaration and renders inputs grouped under the skill's name. The user's value lands in `~/.halo/secrets/settings.yaml` at `<skill-id>.params.api_key`.
141
+ The Settings page reads this declaration and renders inputs grouped under the skill's name. The user's value lands in `~/.halo/secrets/settings.yaml` at `<skill-id>.params.api_key`. Among built-ins, `extension` declares a plain `hub_repo` param and `web-search` plain `fast_region` / `deep_region` params; `halo setup` asks only for built-in skills' `secret: true` params, so plain ones are set in Settings → Skills.
142
142
 
143
143
  > Credentials **do not** belong directly in `settings.yaml` — use `<<ENV_NAME>>` placeholders with the real value in env vars, or type them once into the Settings page (which still encrypts in the UI but stores plaintext on disk). Don't write credentials in SKILL.md either. SKILL.md + the schema in `config.yaml` are meant to be shareable; values are the user's.
144
144
 
@@ -158,9 +158,9 @@ Halo seeds these skills on every startup (the ids in `BUILTIN_SKILL_IDS`, `packa
158
158
  | send-file | Deliver an image/video/file as a channel attachment by emitting `MEDIA:<absolute_path>` — works on Web / WeChat / Telegram / Slack / Feishu (no command; model-activated, workspace access) |
159
159
  | self | The agent's own visual space (`.halo/canvas/self.html`) for self-expression (no command; model-activated, full access) |
160
160
  | aws-knowledge | Query the official AWS Knowledge MCP server for up-to-date AWS docs (no command, `user-invocable: false`; model auto-activates) |
161
- | web-search | Real-time web search in two gears: fast (Amazon Nova grounding, default) and deep (`--deep`, GPT-5.6 `web_search` via Bedrock Mantle, slower and token-expensive) (no command, `user-invocable: false`; model auto-activates) |
161
+ | web-search | Real-time web search on AWS Bedrock (needs the machine's AWS credentials with Bedrock access) in two gears: fast (Amazon Nova grounding, default; US regions only) and deep (`--deep`, GPT-5.6 `web_search` via Bedrock Mantle, slower and token-expensive); regions set via its `fast_region` / `deep_region` params (Settings → Skills) (no command, `user-invocable: false`; model auto-activates) |
162
162
  | halo | Platform self-knowledge — config directories, MD scopes, which bundled doc answers what (no command, `user-invocable: false`; model auto-activates) |
163
- | extension | Install / list / remove canvas preview extensions — backs `/extension` (full access) |
163
+ | extension | Install / list / remove canvas preview extensions — backs `/extension` (full access); its `hub_repo` param (Settings → Skills) picks the hub `install <id>` pulls from (default turmind/halo-hub) |
164
164
 
165
165
  All live under `~/.halo/global/skills/` and are force-overwritten on startup. To customize one, copy it into `<project>/.halo/skills/<id>/` and edit there (workspace overrides global). `tavily-web-search` remains an optional (non-seeded) skill.
166
166
 
@@ -85,11 +85,11 @@ Special-file previews:
85
85
 
86
86
  ### Preview extensions
87
87
 
88
- File types the editor has no built-in viewer for (e.g. `.glb` 3D models) show a "no built-in preview" page with a link to [halo-hub](https://github.com/turmind/halo-hub), where ready-made viewer extensions live. Extensions are installed **server-wide** (`~/.halo/global/extensions/<id>/`) and take effect in every open tab immediately — no restart, no page reload.
88
+ File types the editor has no built-in viewer for (e.g. `.glb` 3D models) show a "no built-in preview" page with a link to [halo-hub](https://github.com/turmind/halo-hub), where ready-made viewer extensions live: 3D models (`glb` — `.glb` / `.gltf` / `.obj` / `.stl`), draw.io diagrams (`drawio`, editable), Excalidraw whiteboards (`excalidraw`, editable) and Jupyter notebooks (`ipynb`, read-only). Extensions are installed **server-wide** (`~/.halo/global/extensions/<id>/`) and take effect in every open tab immediately — no restart, no page reload.
89
89
 
90
90
  Three ways to install:
91
91
  - **Settings → Extensions**: upload the extension `.zip`; the same panel lists what's installed (version, file types) and removes extensions
92
- - **Ask the agent**: `/extension install glb` pulls the latest `glb-v*` release from halo-hub; `/extension install /path/to/x.zip` or an `https://…zip` URL also work. `/extension list` and `/extension remove <id>` round it out (full-access sessions only)
92
+ - **Ask the agent**: `/extension install glb` pulls the latest `glb-v*` release from the configured hub — halo-hub unless you change **Settings → Skills → extension → `hub_repo`** (a GitHub / Gitea / Forgejo / GitLab repo URL, `owner/repo` for GitHub, or any git URL / local repo, which installs from `glb-v<x.y.z>` tags); `/extension install /path/to/x.zip` or an `https://…zip` URL also work. `/extension list` and `/extension remove <id>` round it out (full-access sessions only)
93
93
  - **By hand**: drop an unpacked extension directory into `~/.halo/global/extensions/` — the server watches that directory
94
94
 
95
95
  Once installed, matching files open in the extension's viewer. If more than one viewer can show a file, the preview header gets an **Open with** menu (per tab, not remembered). Extensions that declare the `save` capability can edit the file: the tab shows the usual dirty dot, and the header **Save** button writes it back (Ctrl/Cmd+S doesn't apply to extension tabs). If the file changed on disk in between, you're asked before overwriting.
@@ -60,8 +60,9 @@ File types without a built-in plugin can be handled by an **installed extension*
60
60
  - **Resolution order** for a file extension: `default`-priority extensions (newest install wins) → built-in plugins → `option`-priority extensions. `.glb` with the `glb` extension installed opens in the viewer directly; an `option` extension only shows up in the header's **Open with** menu
61
61
  - **Opening and tab restore**: whether a file opens as text or in a viewer is decided when it is clicked, restored after a page reload, or opened to the side. Since an installed extension may claim a type (`.glb`) or even a text suffix (`.json`), a file that would open as text first waits for the page's initial extension list (up to 3 s), so a `.glb` tab survives a reload as a viewer rather than coming back as text. Files that already have a built-in viewer open without waiting. If the list isn't back within 3 s (or failed to load), files open as if no extension were installed — a `.glb` opens as text; a list that arrives later still updates the viewer of tabs that are already previews
62
62
  - **Open with** (header menu, shown only when there are >2 candidates counting *Open as text*): switches the current tab's viewer; the choice is per tab and not persisted
63
- - **Fallback**: a file with no built-in plugin and no installed extension shows a static page — "no built-in preview for this type; extensions for more file types are on halo-hub ↗" — plus Open as text / Download. No online lookup
63
+ - **Fallback**: a file with no built-in plugin and no installed extension shows a static page — "no built-in preview for this type; extensions for more file types are on <hub> ↗", where `<hub>` is the extension skill's `hub_repo` (default halo-hub; a local path / ssh remote shows as text) — plus Open as text / Download. No online lookup
64
64
  - **Editable extensions** (manifest `capabilities: ["save"]`): the iframe reports dirty → tab shows the dirty dot → header **Save** button (Ctrl/Cmd+S is *not* wired for extension tabs — Monaco isn't mounted) → `PUT /api/files/raw`. If the file changed on disk since it was loaded the save gets a 409 and the user is asked "changed on disk … Overwrite?". An external change while the tab is clean reloads it; while dirty, local edits are kept silently and the conflict surfaces at the next save
65
+ - **Install sources**: Settings → Extensions zip upload; `/extension install <id|zip|url>` (listed by the built-in `default` agent, full access), where an id comes from the hub in Settings → Skills → extension → `hub_repo` (default halo-hub; GitHub / Gitea / Forgejo / GitLab release zips, or tags of any other git repo); or a manual directory drop
65
66
  - **Live install / upgrade / uninstall** (from Settings → Extensions upload / remove, the `/extension` skill, or a manual directory change): pushed over WS, no reload. An upgraded extension remounts open tabs (or shows a "updated — reload" banner if the tab is dirty); uninstalling drops clean tabs to the fallback page and keeps dirty tabs alive with a banner
66
67
  - **Settings → Extensions**: lists installed extensions (name, version, file types, license/homepage) and manifest errors; upload `.zip` (≤ 100 MB) and remove. Any logged-in admin can do both — the admin cookie has no access level
67
68
 
@@ -73,7 +74,7 @@ Markdown and HTML open as text in Monaco *and* have a rendered view — Canvas d
73
74
  - Toggle to Edit → Monaco source, Cmd+S saves as usual
74
75
 
75
76
  ### Preview caching (MRU)
76
- Recently opened preview tabs stay mounted (up to 5, MRU) so switching between them doesn't re-fetch or re-parse. Plugins flagged `heavy: true` bypass the cache — only the active instance mounts, others unmount. An extension tab with unsaved edits stays pinned beyond the 5. Closing a preview tab removes it from the cache immediately (aborting any in-flight fetch).
77
+ Recently opened preview tabs stay mounted (up to 5, MRU) so switching between them doesn't re-fetch or re-parse. Plugins flagged `heavy: true` bypass the cache — only the active instance mounts, others unmount; switching between two heavy tabs of the same type (e.g. two `.ipynb`) remounts the viewer, so it shows the newly active file. An extension tab with unsaved edits stays pinned beyond the 5. Closing a preview tab removes it from the cache immediately (aborting any in-flight fetch).
77
78
 
78
79
  ### File metadata in header
79
80
  The Canvas header shows `(size · Created … · Modified …)` for the active tab. Both text tabs and preview tabs populate this — preview tabs fetch `GET /api/files/stat` on open (and on tab restore from localStorage) since they never read content.