@turmind/halo 1.5.11 → 1.6.1

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 (75) hide show
  1. package/README.md +2 -2
  2. package/admin-out/404.html +1 -1
  3. package/admin-out/_next/static/chunks/{941-4719e7343b54b9bd.js → 147-d4200c70445aabb6.js} +1 -1
  4. package/admin-out/_next/static/chunks/58.d26b1c4f14404226.js +1 -0
  5. package/admin-out/_next/static/chunks/989-ddba0eb72566da23.js +1 -0
  6. package/admin-out/_next/static/chunks/app/{layout-5ec6c5f713a8562b.js → layout-ea161287d934ba96.js} +1 -1
  7. package/admin-out/_next/static/chunks/app/page-be47983ba8ff20b1.js +1 -0
  8. package/admin-out/_next/static/chunks/webpack-74f577c1b094f942.js +1 -0
  9. package/admin-out/_next/static/css/3b4dcd0ac6b8eae8.css +1 -0
  10. package/admin-out/_next/static/css/7b736202f0b1dbfd.css +1 -0
  11. package/admin-out/index.html +1 -1
  12. package/admin-out/index.txt +6 -5
  13. package/bundled-docs/design/a2a.md +286 -0
  14. package/bundled-docs/design/agent.md +6 -4
  15. package/bundled-docs/design/agentcore.md +194 -122
  16. package/bundled-docs/design/architecture.md +2 -2
  17. package/bundled-docs/design/canvas-extensions.md +59 -12
  18. package/bundled-docs/design/cron.md +22 -11
  19. package/bundled-docs/design/observability.md +12 -4
  20. package/bundled-docs/design/prompt-system.md +10 -5
  21. package/bundled-docs/design/relay.md +1 -1
  22. package/bundled-docs/design/session.md +7 -2
  23. package/bundled-docs/design/storage.md +40 -10
  24. package/bundled-docs/design/web.md +5 -56
  25. package/bundled-docs/design/ws.md +1 -1
  26. package/bundled-docs/dev/add-model-provider.md +15 -9
  27. package/bundled-docs/dev/api.md +93 -14
  28. package/bundled-docs/dev/deploy.md +5 -1
  29. package/bundled-docs/dev/desktop-packaging.md +11 -5
  30. package/bundled-docs/dev/env.md +9 -5
  31. package/bundled-docs/dev/previews.md +26 -0
  32. package/bundled-docs/dev/tools.md +93 -3
  33. package/bundled-docs/guide/agents.md +6 -6
  34. package/bundled-docs/guide/channels/web.md +0 -26
  35. package/bundled-docs/guide/cli.md +45 -8
  36. package/bundled-docs/guide/delegation-and-access.md +3 -2
  37. package/bundled-docs/guide/skills.md +13 -7
  38. package/bundled-docs/guide/workspace.md +1 -1
  39. package/bundled-docs/requirements/agent.md +6 -1
  40. package/bundled-docs/requirements/chat.md +9 -3
  41. package/bundled-docs/requirements/command.md +4 -0
  42. package/bundled-docs/requirements/editor.md +28 -8
  43. package/bundled-docs/requirements/skill.md +15 -9
  44. package/bundled-docs/requirements/workspace.md +6 -0
  45. package/dist/index.js +6081 -3013
  46. package/package.json +1 -1
  47. package/templates/models/anthropic.yaml +109 -13
  48. package/templates/models/aws-bedrock-claude-invoke.yaml +76 -11
  49. package/templates/models/aws-bedrock-mantle.yaml +60 -28
  50. package/templates/models/aws-bedrock-openai.yaml +146 -29
  51. package/templates/models/deepseek.yaml +7 -3
  52. package/templates/models/doubao.yaml +72 -20
  53. package/templates/models/hunyuan.yaml +74 -14
  54. package/templates/models/kimi.yaml +63 -3
  55. package/templates/models/mimo-token-plan-china.yaml +9 -1
  56. package/templates/models/minimax.yaml +9 -3
  57. package/templates/models/openai.yaml +120 -17
  58. package/templates/models/qwen.yaml +90 -4
  59. package/templates/models/zhipu.yaml +9 -1
  60. package/templates/prompts/all/RUNTIME.md +2 -1
  61. package/templates/schema.sql +1 -1
  62. package/templates/skills/cron/SKILL.md +38 -2
  63. package/templates/skills/cron/manage-cron.py +115 -7
  64. package/templates/skills/send-file/SKILL.md +1 -1
  65. package/templates/skills/send-file/a2a.md +13 -0
  66. package/templates/skills/skill/SKILL.md +22 -11
  67. package/templates/skills/workspace/share.md +5 -2
  68. package/templates/skills/workspace/stage.py +51 -14
  69. package/admin-out/_next/static/chunks/220.7566bc89f3d98993.js +0 -1
  70. package/admin-out/_next/static/chunks/989-86b0f677f69ccf3b.js +0 -1
  71. package/admin-out/_next/static/chunks/app/page-4792aa1c79f7e7f1.js +0 -1
  72. package/admin-out/_next/static/chunks/webpack-efd27999305b080a.js +0 -1
  73. package/admin-out/_next/static/css/97f3ac570664d044.css +0 -3
  74. /package/admin-out/_next/static/{GO2W5JoiC46FIxLjtFAXx → j6buZnQDPISEFgtAuTGyl}/_buildManifest.js +0 -0
  75. /package/admin-out/_next/static/{GO2W5JoiC46FIxLjtFAXx → j6buZnQDPISEFgtAuTGyl}/_ssgManifest.js +0 -0
@@ -138,7 +138,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
138
138
  | `chat:stopped` | `chat:stop` / `chat:interrupt` handlers (this client only) | Stop/interrupt acknowledged — `{sessionId}` |
139
139
  | `session:compacted` | compact handler | Compaction complete — `{message?, contextTokens, sessionId}` |
140
140
  | `compact:started` / `compact:summarizing` / `compact:done` | compact handler; `ws/event-processor.ts` | Compaction progress, `{sessionId}`. 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`). |
141
- | `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). |
141
+ | `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. `cron:job_changed`: `{jobId, kind?}` (`created` / `updated` / `deleted` / `reconciled` / `missed`; a run's finalize sends `lastRunStatus` + `lastRunAt` instead), plus — whenever the job still exists, except `missed` — `workingDir` (workspace-relative, `null` = root), `stateless` (boolean) and `sessionAgentId` (agent of the job's existing session, `null` when stateless / not created / unreadable; the job's agent is locked to it). `cron:run_changed`: `{jobId, runId, status}`. See [cron.md](cron.md). |
142
142
  | `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). |
143
143
  | `models:changed` | `models/watcher.ts` `reloadAndBroadcast` (broadcast to all clients) | `~/.halo/global/models.d/` changed (hub provider yamls installed by `halo models install` / `/extension models`, or removed by hand) and the merged registry differs from the last one — the server has already dropped its registry cache. No payload: the admin bumps its models bus, and the Agents panel / agent form, the composer's image-capability check and the Settings page re-fetch `/agent-configs/models` / the settings schema. Also bumped on WS reconnect (a frame lost while down). Global, no workspace marker. See [storage.md](storage.md#model-registry-format). |
144
144
  | `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). |
@@ -27,13 +27,13 @@ Runtime names (`runtime:` value → class): `bedrock-invoke` → `BedrockAgent`,
27
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
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
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 |
30
+ | `hunyuan` | `hunyuan` | `HunyuanAgent` | `hunyuan-agent.ts` | Tencent Hy3 / Hy4 preview / 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, thinking off sends `reasoning_effort: 'none'` (an omitted field thinks by default — live-probed 2026-10-10), automatic prompt caching (cached_tokens), no vision |
31
+ | `doubao` | `doubao` | `DoubaoAgent` | `doubao-agent.ts` | ByteDance Doubao Seed 2.1 Pro + 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. Images go out as `image_url` content parts; only 2.1 Pro is `image: true` (the 2.0 models misread a red PNG), so the registry flag gates the rest |
32
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'`. |
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). Ships a short Bedrock Mantle list callable with a Bedrock API key (`openai.gpt-5.5` default, `openai.gpt-5.4`; `defaultEndpoint` Mantle us-east-1 `/openai/v1`, presets us-east-1 / us-west-2 / api.openai.com — GPT-6.x / 5.6 reject `max_tokens`, so they stay on the Responses-API providers) plus the legacy `gpt-4o` placeholder (official API only); any other endpoint + model id can be typed, and capabilities for unlisted ids are 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. Ships a short Bedrock Mantle list callable with a Bedrock API key as `x-api-key` (`anthropic.claude-opus-5-5` default, `anthropic.claude-haiku-4-5`; `defaultEndpoint` Mantle us-east-1 `/anthropic`, presets us-east-1 / us-west-2 / api.anthropic.com) plus the legacy `claude-sonnet-4-6` placeholder (official API only); any other endpoint + model id can be typed. Streaming via `fetchAnthropicStream` (the gateway must support `stream: true` SSE — the reference Anthropic behaviour). Standard `cache_control: ephemeral` with optional `ttl: '1h'`. |
35
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). |
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` (us-east-1 / us-west-2 / us-east-2, default us-east-1, `openai.gpt-6-sol` default model) and pins each model's regions with per-model `endpoints:` (Astra us-west-2; GPT-6.1 Sol / GPT-6 Sol / Luna us-east-1; GPT-5.6 Sol us-east-1 + us-east-2; Terra / Luna none = all presets), so the form only offers — and on a model switch moves to — a region that serves the model; `extractRegionFromHost` falls back to us-east-1. GPT-6 Astra / Sol / Luna: 1,050,000 context / 128,000 max output; GPT-6.1 Sol 1M / 131,072 (card; Mantle accepts any `max_output_tokens` ≥ 16, so the cap isn't enforced server-side); same effort (none/low/medium/high/xhigh/max) + verbosity surface. Input beyond 272K tokens bills at 2x, so every entry ships `contextWindow: 272000` (what a new agent gets) with the 1M-class figure as `maxContextWindow`; raise `context.maxTokens` past 272K only on purpose. 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
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. |
38
38
 
39
39
  ---
@@ -164,12 +164,17 @@ revision: 2026100501 # required for bundled / hub yamls — in
164
164
  displayName: My Provider
165
165
  description: Short description
166
166
  defaultEndpoint: https://api.example.com/v1
167
+ endpointPresets: # optional — the form's endpoint dropdown
168
+ - https://api.example.com/v1
167
169
 
168
170
  models:
169
171
  - id: my-model-v1
170
172
  displayName: My Model V1
173
+ # endpoints: [https://api.example.com/v1] # optional — only when this model is served on a subset of the presets
171
174
  maxOutputTokens: 16384
172
- contextWindow: 200000 # official context window in tokens (optional)
175
+ contextWindow: 200000 # recommended window for a new agent: official figure, ≤ 272000
176
+ maxContextWindow: 200000 # the model's true limit (absent → contextWindow)
177
+ compressAt: 0.9 # compaction ratio for a new agent, (0, 1]
173
178
  capabilities:
174
179
  image: true # supports image input
175
180
  video: false # no video understanding
@@ -185,12 +190,13 @@ models:
185
190
 
186
191
  **Capability flags drive the UI.** The Form view reads this file directly to decide which controls to render:
187
192
  - `defaultEndpoint` → auto-fills endpoint when switching provider in agent/settings UI
193
+ - `endpointPresets` → the endpoint dropdown; a model's own `endpoints:` replaces it for that model, and the form moves the endpoint onto that list when the user switches to the model (`resolveModelEndpoints`, `models/model-endpoints.ts` + the admin copy)
188
194
  - `capabilities.image` / `capabilities.video` / `capabilities.audio` → modality badges (green = supported, grey = unsupported); also controls runtime behavior in two ways: (a) the `view_image` workspace tool is dropped from the agent's tool list at session-create time when `image: false`, so the model never sees a tool the provider would 400 on; (b) user-supplied images on inbound messages are filtered with a text notice instead of being sent to the API
189
195
  - `capabilities.promptCaching.ttlPresets` → renders the Prompt Caching dropdown with those options
190
196
  - `capabilities.thinking.effortPresets` → renders the Thinking effort dropdown
191
197
  - Omit either block → the UI hides that control for this model
192
198
 
193
- **`contextWindow` feeds the session context budget** (compaction threshold, token ring). Resolution priority: agent.yaml `context.maxTokens` > registry `contextWindow` > global default 272K (`config.model.maxContextTokens`). Resolved by `resolveContextWindow()` in [config.ts](../../../packages/server/src/config.ts), consumed at two points with the same priority chain: `buildModelRuntime` (session-agent-builder.ts, live sessions) and the cold-session `getContextConfig()` path (session-manager.ts, display for sessions not in memory). Declare `contextWindow` on every model entry: the official number, capped at 272000 — bundled entries with a larger native window (1M-class) are pinned to 272K. Without it, small-window models (e.g. 128K) silently use the 272K default and overflow before compaction kicks in.
199
+ **Context fields** — `contextWindow` is the *recommended* window, written into a new agent's `context.maxTokens`: the official figure, capped at 272000 (1M-class models bill input past 272K at long-context rates); `maxContextWindow` is the model's true limit; `compressAt` the new agent's compaction ratio. Read through `resolveModelContext()` / `resolveEffectiveContext()` in [config.ts](../../../packages/server/src/config.ts). At runtime a session's budget (compaction threshold, token ring) is agent.yaml `context.maxTokens` / `compressAt`, else the global defaults (`config.model.maxContextTokens` 272K capped at the model's max, `general.compact.compress_at` 0.9), then clamped to the model's max — the same chain for live sessions (`buildModelRuntime`, session-agent-builder.ts) and cold ones (`getContextConfig()`, session-manager.ts). The registry's recommended `contextWindow` / `compressAt` and `capabilities.verbosity.default` are **scaffold-only**: written into agent.yaml when an agent is created or the form switches model, never read at runtime for an existing agent. A new agent's compressAt is the explicitly set global `compress_at`, else the model's `compressAt`, else 0.9. Declare all three on every model entry; without `maxContextWindow` / `contextWindow` a small-window model (e.g. 128K) runs at 272K and overflows before compaction kicks in. Details: [design/storage.md#model-registry-format](../design/storage.md#model-registry-format).
194
200
 
195
201
  Shape source: [packages/server/templates/models/aws-bedrock-claude-invoke.yaml](../../../packages/server/templates/models/aws-bedrock-claude-invoke.yaml) and [design/storage.md#model-registry-format](../design/storage.md#model-registry-format).
196
202
 
@@ -198,7 +204,7 @@ Shape source: [packages/server/templates/models/aws-bedrock-claude-invoke.yaml](
198
204
 
199
205
  ### Distributing a provider change through the hub
200
206
 
201
- A new model, a renamed model id or a changed endpoint doesn't need a halo release: edit the yaml in [halo-hub](https://github.com/turmind/halo-hub) `models/`, bump its `revision`, `node scripts/pack.mjs models`, and publish the zip as release `models-v<YYYY.MM.DD>` (see the hub README). Users run `/extension models` (or say "更新模型列表"); `halo models install` writes the files into `models.d/`, and the server's `models.d` watcher drops the registry cache and pushes `models:changed`, so open admins re-fetch — no restart, no reload. Install-time refusals: a `runtime:` this build doesn't know ("needs a newer halo" — a **new runtime class always needs a halo release**), a missing `revision`, a `secrets[].default` other than empty / `<<ENV_NAME>>`. A new provider or a changed `defaultEndpoint` / `endpointPresets` is listed and needs the user's explicit OK (`--yes`). Mirror the change in `templates/models/` with the same revision so the next release bundles it.
207
+ A new model, a renamed model id or a changed endpoint doesn't need a halo release: edit the yaml in [halo-hub](https://github.com/turmind/halo-hub) `models/`, bump its `revision`, `node scripts/pack.mjs models`, and publish the zip as release `models-v<YYYY.MM.DD>` (see the hub README). Users run `/extension models` (or say "更新模型列表"); `halo models install` writes the files into `models.d/`, and the server's `models.d` watcher drops the registry cache and pushes `models:changed`, so open admins re-fetch — no restart, no reload. Install-time refusals: a `runtime:` this build doesn't know ("needs a newer halo" — a **new runtime class always needs a halo release**), a missing `revision`, a `secrets[].default` other than empty / `<<ENV_NAME>>`. Also refused: model `contextWindow` / `maxContextWindow` that aren't positive integers, `contextWindow` > `maxContextWindow`, `compressAt` outside (0, 1], `endpoints` that isn't a list of `https://` URLs. A new provider, a changed `defaultEndpoint` / `endpointPresets`, or a model `endpoints:` URL the provider didn't use anywhere before is listed and needs the user's explicit OK (`--yes`). Mirror the change in `templates/models/` with the same revision so the next release bundles it.
202
208
 
203
209
  ### 3. Wire the dispatcher
204
210
 
@@ -289,7 +295,7 @@ If something's wrong, server logs under `~/.halo/global/logs/server.log` are the
289
295
 
290
296
  (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.)
291
297
 
292
- 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:
298
+ Two of the providers above are deliberately generic — they take whatever `endpoint` + `model id` the user fills in the form. Their model list is short on purpose: a few Bedrock Mantle models that work out of the box with a Bedrock API key (the default endpoint is Mantle us-east-1) plus one legacy placeholder, and the models census skips them — new models are tracked through the `aws-bedrock-*` providers. Use them when:
293
299
 
294
300
  - 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.
295
301
  - You don't want to maintain a model registry for that vendor — users are expected to know what model id and endpoint to use.
@@ -54,10 +54,12 @@ All file operations validate the path stays inside the project root (prevents pa
54
54
  |---|---|---|
55
55
  | GET | `/api/fs/home` | Returns server `homedir()` — fallback when the frontend has no `?folder=` URL param. Returns `{ home }`. |
56
56
  | GET | `/api/fs/exists?path=/abs` | Validates an absolute path exists and whether it's a directory. Returns `{exists, isDirectory?}`. |
57
- | GET | `/api/fs/browse?path=/abs` | Lists immediate directory children (hidden ones dropped). Returns `{path, parent, entries: [{name, path}]}`. |
57
+ | GET | `/api/fs/browse?path=/abs` | Lists immediate directory children (hidden ones dropped). Returns `{path, parent, entries: [{name, path}]}`. With `files=1` (the extension file picker, capability `fs-read`): files too — `entries: [{name, path, type: 'file'\|'directory', size?}]`, directories first, dot-entries shown, only the file tree's noise (`.git`, `.DS_Store`, `node_modules`, `__pycache__`) skipped, symlinks counted as what they point at; `sizes=1` adds each file's `size`. A directory on the sandbox hidden lists answers 404 like a missing one, and hidden entries are left out of the listing. |
58
+ | GET | `/api/fs/stat?path=/abs` | `fs-read` `scope: 'system'` stat. Returns `{path, realPath, size, modifiedAt, isDirectory}` (`realPath` resolves symlinks). 400 `absolute path without .. required`; 404 for a missing path **and** for one on the sandbox hidden lists (same message); 403 on `EACCES` / `EPERM`. |
59
+ | GET | `/api/fs/raw?path=/abs` | `fs-read` `scope: 'system'` read: the file's bytes, streamed (`application/octet-stream` + `Content-Length`; the stream is destroyed if the client aborts). Same path rules and error shapes as `/api/fs/stat`; 400 `not a file` for a directory. |
58
60
  | POST | `/api/fs/workspace/resolve` | Resolve `{path}` to an absolute path and run `ensureWorkspaceHalo()`. Returns `{id, path}`. Used by the workspace-picker on switch. 404 `path not found` when it doesn't exist; 400 `not a directory` for a file; 400 `filesystem root cannot be a workspace` for `/` (or a drive root) — `ensureWorkspaceHalo()` writes a whole `.halo/` scaffold, so those two shapes are refused before the write. Deeper system trees (`/usr`, `/etc`) are deliberately **not** banned. |
59
61
 
60
- Absolute paths only. Purpose: Explorer's workspace picker and switching validation. `/api/fs/browse` is intentionally unrestricted (it's a read-only picker over the server's filesystem — same trust boundary as the admin cookie). Reading/writing files still goes through `/api/files/*` and remains project-sandboxed.
62
+ Absolute paths only. Purpose: Explorer's workspace picker and switching validation. `/api/fs/browse` is intentionally unrestricted (it's a read-only picker over the server's filesystem — same trust boundary as the admin cookie). Reading/writing files still goes through `/api/files/*` and remains project-sandboxed. The one exception is the read-only `/api/fs/stat` + `/api/fs/raw` + `/api/fs/browse?files=1` trio behind the `fs-read` extension capability: they take absolute paths (no `..` segment) and refuse the sandbox hidden lists (`~/.halo/secrets`, `~/.ssh`, `.git-credentials`, the global dbs … — `isHiddenHostPath` in `tools/sandbox.ts`, the same lists the agent sandbox masks) by **realpath**, so a symlink can't reach them. They are admin-cookie routes, never in `PUBLIC_PATHS`; `fs-read` itself is host-side UX gating (the extension iframe is same-origin and could call them directly), which is why the guard lives on the routes.
61
63
 
62
64
  ## Data Preview
63
65
 
@@ -93,6 +95,14 @@ Installed extensions are directories under `~/.halo/global/extensions/<id>/` wit
93
95
  | DELETE | `/api/extensions/:id` | cookie | Remove the directory. 400 for an invalid id shape, 404 if not installed. Returns `{ok, id}` |
94
96
  | GET | `/api/extensions/:id/:version/:token/<asset>` | **path token, no cookie** | Static file from the extension directory. 401 unless `verifyScopedToken(token,'ext')`; 404 if `version` ≠ installed version or the asset escapes the directory. Response headers: `Access-Control-Allow-Origin: *`, `Referrer-Policy: no-referrer`, `Cache-Control: public, max-age=31536000, immutable` (the version segment is the cache key) |
95
97
  | WS | `/api/transcribe/stream?ext=<id>&lang=<auto\|xx-XX>` | cookie | Streaming transcription proxy to Amazon Transcribe (`routes/transcribe-ws.ts`; not mounted in AgentCore mode). Handshake 401 without the login cookie, 403 unless `ext` is installed and declares capability `transcribe`. Client → binary PCM s16le mono 16 kHz, text `{"type":"end"}` (flush, server closes 1000). Server → `{"type":"ready"}` (on connect; stream audio right after it), `{"type":"partial"\|"final",start,end,text,lang}` (seconds from the stream's first audio byte), `{"type":"error",code,message}` then close; `code` ∈ `credentials` \| `denied` \| `limit` \| `bad-request` \| `io`. Region / languages / credentials from the extension's `ext-<id>` settings (default chain when no keys) — see [design](../design/canvas-extensions.md#streaming-transcription-proxy) |
98
+ | GET | `/api/jupyter/status` | cookie | Notebook kernels (`routes/jupyter.ts`; not mounted in AgentCore mode). `{ enabled, available, version?, error?, hint? }` — never starts the server; disabled → `enabled:false, available:false`; Jupyter missing → `available:false` + `error` + `hint: 'pip install jupyter_server ipykernel'` |
99
+ | GET | `/api/jupyter/kernelspecs` | cookie | `{ default, specs: [{ name, displayName, language }] }`. Starts the local jupyter server on demand; 503 `{error, hint?}` when disabled / unavailable / it fails to start |
100
+ | POST | `/api/jupyter/kernels` | cookie | Body `{ projectId, path, name }` (`path` = workspace-relative notebook path, as in `init.file.path`). 400 for a missing field, unknown project, path outside the workspace or a missing directory. Starts kernel `name` with cwd = the notebook's directory → `{ id, name }` |
101
+ | POST | `/api/jupyter/kernels/:id/interrupt` · `…/restart` | cookie | `{ ok: true }`; 404 unless `:id` is a kernel Halo started (and jupyter still has) |
102
+ | DELETE | `/api/jupyter/kernels/:id` | cookie | Shut the kernel down → `{ ok: true }`; 404 for an unknown id |
103
+ | WS | `/api/jupyter/channels?kernel=<id>` | cookie | Kernel channels: frames piped as-is both ways to the upstream `/api/kernels/<id>/channels` — legacy JSON text protocol (`channel` field), no subprotocol. Handshake 401 without the cookie, 404 for an unknown kernel. The last socket closing starts a 60 s grace, then the kernel is shut down — see [design](../design/canvas-extensions.md#notebook-kernels-jupyter-proxy) |
104
+
105
+ Extension capabilities the host understands (`ExtensionCapability`): `save`, `media`, `transcribe`, `fs-read`; an unknown value makes the manifest an error. `fs-read` adds no extension-specific route: the host serves the extension's scoped `fs` frames and `pick` file picker from the [Filesystem browse](#filesystem-browse-not-project-scoped--for-the-workspace-picker) routes above (`scope: 'system'`) and the ordinary `/api/files/*` routes (`scope: 'workspace'`) — see [design/canvas-extensions.md](../design/canvas-extensions.md#scoped-file-access-and-the-picker-fs-read).
96
106
 
97
107
  **Why the asset route isn't cookie-authed.** The admin first hosted extensions in `<iframe sandbox="allow-scripts">` without `allow-same-origin` — an opaque origin. The document navigation still carries the cookie, but every subresource it loads (classic/module scripts, img, css, fetch, wasm, dynamic import, Worker) is a cross-site request with **no cookie**, and module/fetch/wasm need `Access-Control-Allow-Origin` too. A `?token=` query would be dropped when the document resolves `./viewer.js`-style relative URLs, so the token is a **path segment** that every relative URL inherits. The host now grants `allow-same-origin` (a cookie-auth proxy in front of halo — CloudFront + midway, oauth2-proxy — needs its *own* cookie on those requests, which only a same-origin iframe sends; see [design/canvas-extensions.md](../design/canvas-extensions.md#asset-serving-and-the-scoped-token)), so the admin cookie does travel again, but the route keeps the path token as its single auth path. `authMiddleware` lets exactly that path shape (`EXTENSION_ASSET_PATH = /^\/api\/extensions\/[^/]+\/[^/]+\/[^/]+\/./`) through without a cookie and the route verifies the token itself. The token is good for nothing else: `validateToken` (admin cookie / WS upgrade) refuses any JWT payload that carries a `scope`, so an asset token replayed as `halo_token` is a 401.
98
108
 
@@ -113,7 +123,7 @@ Unified session log API — list + read session files across all agents.
113
123
  | PATCH | `/api/sessions/logs/:id?projectId=` | Rename a session (admin-only; body `{title}`) — 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 |
114
124
  | GET | `/api/sessions/goal?projectId=` | Legacy goal binding of the workspace (`{goal: {goalSessionId, workerSessionId, status, round, maxRounds} \| null}`); refresh seed for the goal banner, which only shows when `general.goal_mode_enabled` is on |
115
125
 
116
- The list endpoint returns flat metadata (id / agentId / agentName / title / timestamps / exchangeCount / parentSessionId / stoppedAt / archivedAt / contextTokens / totalOutputTokens / status / 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`.
126
+ The list endpoint returns flat metadata (id / agentId / agentName / title / timestamps / exchangeCount / parentSessionId / stoppedAt / archivedAt / contextTokens / totalOutputTokens / status / workingDir / goalSessionId — `workingDir` is the persisted workspace-relative folder, `null` = the root; `goalSessionId` 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`.
117
127
 
118
128
  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.
119
129
 
@@ -204,9 +214,9 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
204
214
 
205
215
  | Method | Path | Purpose |
206
216
  |---|---|---|
207
- | GET | `/api/cron/jobs?limit=&before=` | Cursor-paginated newest-first by `createdAt`. Default `limit` 20, max 100; `before` is a `createdAt` cursor. Returns `{jobs, hasMore, nextCursor}`. Each job has `targets` decoded, a live `nextRunAt` (computed from schedule/runAt, inside the active window), `sessionId` (`null` = runs in the job's own session), `effectiveSessionId` (the session the cli actually runs in: `sessionId`, else the job's own — same id as the job, `cron-<id>` for a custom id without that prefix; older jobs were pinned to their original `cron-cron-…` session by a cron.db migration), and `activeFrom` / `activeUntil` (`null` = unbounded) |
208
- | POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt, schedule, runAt?, timezone?, timeoutSec?, sessionId?, targets?, enabled?}`. Exactly one of `schedule` (5-field cron) or `runAt` (epoch ms, future) is required; mutually exclusive. `timeoutSec` must be an integer 60–21600 (else 400); unset = runner default 3600. `sessionId` picks an existing (or to-be-created) **root** session of `workspacePath` for the cli to run in — `[A-Za-z0-9_:-]{1,200}`, no `>` sub-session path (else 400); unset / `null` / `''` = the job's own session (id = the job id). A picked session that already exists keeps its own agent and access level (`agentId` only applies when the cli creates it); see [design/cron.md](../design/cron.md#execution-runjob) for the shared-session guards. `activeFrom` / `activeUntil` (epoch ms or `null`, recurring only): the schedule fires only inside `[activeFrom, activeUntil)`; with `runAt` → 400; `activeUntil` must be in the future and after `activeFrom` (else 400); `activeFrom` may be in the past. Returns `{ok, id}` |
209
- | PUT | `/api/cron/jobs/:id` | Partial update over the same fields. Re-validates `schedule` / `runAt` and enforces trigger-mode exclusivity on the **merged** row: both in one body → 400 `schedule and runAt are mutually exclusive`; supplying one side is a mode switch that implicitly clears the other; a body that clears the active side without supplying a replacement → 400 `schedule or runAt required`. `runAt` must be a finite future epoch-ms number or `null`. `timeoutSec` follows the same partial-body contract: omitted = unchanged, `null` = clear back to the default, a number is range-validated (60–21600) same as create. `sessionId` likewise: omitted = unchanged, `null` / `''` = back to the job's own session (an older job pinned to `cron-cron-…` then starts a fresh one), a string is validated same as create. `activeFrom` / `activeUntil` likewise (omitted = unchanged, `null` = clear); the window rules are checked on the **merged** row, so switching to `runAt` while a window is stored needs both bounds cleared in the same body, and a newly supplied `activeUntil` must be in the future (a stored past one doesn't block editing other fields). Flips schedule/unschedule on `enabled`. Returns `{ok}` |
217
+ | GET | `/api/cron/jobs?limit=&before=` | Cursor-paginated newest-first by `createdAt`. Default `limit` 20, max 100; `before` is a `createdAt` cursor. Returns `{jobs, hasMore, nextCursor}`. Each job has `targets` decoded, a live `nextRunAt` (computed from schedule/runAt, inside the active window), `sessionId` (`null` = runs in the job's own session), `effectiveSessionId` (the session the cli actually runs in: `sessionId`, else the job's own — same id as the job, `cron-<id>` for a custom id without that prefix; older jobs were pinned to their original `cron-cron-…` session by a cron.db migration), `activeFrom` / `activeUntil` (`null` = unbounded), `workingDir` (workspace-relative folder the run focuses on; `null` = the workspace root), `stateless` (boolean; `true` = a fresh session every run), and `sessionAgentId` (the agent of the effective session once it exists — the job's agent is locked to it; `null` for a stateless job, a session not created yet, or a session that can't be read) |
218
+ | POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt, schedule, runAt?, timezone?, timeoutSec?, sessionId?, workingDir?, stateless?, targets?, enabled?}`. Exactly one of `schedule` (5-field cron) or `runAt` (epoch ms, future) is required; mutually exclusive. `timeoutSec` must be an integer 60–21600 (else 400); unset = runner default 3600. `sessionId` picks an existing (or to-be-created) **root** session of `workspacePath` for the cli to run in — `[A-Za-z0-9_:-]{1,200}`, no `>` sub-session path (else 400); unset / `null` / `''` = the job's own session (id = the job id). A picked session that already exists keeps its own access level, and its agent must equal `agentId` (agent lock, below); see [design/cron.md](../design/cron.md#execution-runjob) for the shared-session guards. `activeFrom` / `activeUntil` (epoch ms or `null`, recurring only): the schedule fires only inside `[activeFrom, activeUntil)`; with `runAt` → 400; `activeUntil` must be in the future and after `activeFrom` (else 400); `activeFrom` may be in the past. `workingDir` (workspace-relative or absolute; unset / `null` / `''` = the root) must be an existing directory inside `workspacePath` (else 400 `workingDir: …`); stored workspace-relative. `stateless` (boolean, else 400) runs every fire in a fresh session; with `sessionId` → 400 `stateless (new session every run) and sessionId are mutually exclusive`. Agent lock: when the effective session already exists and runs another agent → 400 `session <id> already runs agent <x>; …`; a session db that can't be read → 400 `could not read session <id> from <ws>: …`. Returns `{ok, id}` |
219
+ | PUT | `/api/cron/jobs/:id` | Partial update over the same fields. Re-validates `schedule` / `runAt` and enforces trigger-mode exclusivity on the **merged** row: both in one body → 400 `schedule and runAt are mutually exclusive`; supplying one side is a mode switch that implicitly clears the other; a body that clears the active side without supplying a replacement → 400 `schedule or runAt required`. `runAt` must be a finite future epoch-ms number or `null`. `timeoutSec` follows the same partial-body contract: omitted = unchanged, `null` = clear back to the default, a number is range-validated (60–21600) same as create. `sessionId` likewise: omitted = unchanged, `null` / `''` = back to the job's own session (an older job pinned to `cron-cron-…` then starts a fresh one), a string is validated same as create. `activeFrom` / `activeUntil` likewise (omitted = unchanged, `null` = clear); the window rules are checked on the **merged** row, so switching to `runAt` while a window is stored needs both bounds cleared in the same body, and a newly supplied `activeUntil` must be in the future (a stored past one doesn't block editing other fields). `workingDir` likewise (omitted = unchanged, `null` / `''` = back to the root); a stored folder is re-checked when `workspacePath` changes. `stateless` and the `sessionId` exclusivity are checked on the **merged** row. The agent-lock / read-error 400s (see POST) apply only when the body changes the stored agent, session, `stateless` or `workspacePath` — so an unrelated edit such as `{enabled:false}` always succeeds, even for a job whose session already runs another agent. Flips schedule/unschedule on `enabled`. Returns `{ok}` |
210
220
  | DELETE | `/api/cron/jobs/:id` | Unschedule, delete every `cron_runs` row for the job, then delete the job. Returns `{ok}` |
211
221
  | POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget). Returns `{ok}` |
212
222
  | POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
@@ -217,7 +227,7 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
217
227
 
218
228
  | Method | Path | Purpose |
219
229
  |---|---|---|
220
- | GET | `/api/cron/jobs/:id/runs?limit=&before=` | Cursor-paginated newest-first by `cron_runs.id` (ISO-prefixed; sorts as text). Default `limit` 20, max 100. Returns `{runs, hasMore, nextCursor}`; each row has `dispatchResults` JSON-decoded |
230
+ | GET | `/api/cron/jobs/:id/runs?limit=&before=` | Cursor-paginated newest-first by `cron_runs.id` (ISO-prefixed; sorts as text). Default `limit` 20, max 100. Returns `{runs, hasMore, nextCursor}`; each row has `dispatchResults` JSON-decoded and `sessionId` (the session the run used — a stateless job's per-run `cron-<id>-<base36 ms>`; `null` on runs from before the column, and on `skipped` rows) |
221
231
  | GET | `/api/cron/runs/:runId/log` | Read the raw log file for one run. Returns `{log}` (string or null when the file is missing / past retention) |
222
232
 
223
233
  ### Reference data (for the create form)
@@ -315,6 +325,60 @@ SSE responses (`/api/web/chat`, `/api/web/subscribe`) carry an SSE comment line
315
325
 
316
326
  See [design/web.md](../design/web.md).
317
327
 
328
+ ## A2A (Agent-to-Agent)
329
+
330
+ Files: `packages/server/src/a2a/routes.ts` (JSON-RPC handler), `exposure.ts` (path resolution, auth, card), `outbound.ts` (push receiver). A2A v1.0, JSON-RPC binding only. Mounted in normal server mode outside `/api/*` (no admin cookie) and before the static frontend; not in the CLI / TUI. AgentCore mode serves the same handler at `/` instead — see [AgentCore runtime container](#agentcore-runtime-container-halo-agentcore-only). Design: [design/a2a.md](../design/a2a.md).
331
+
332
+ | Method | Path | Purpose |
333
+ |---|---|---|
334
+ | POST | `/a2a/<rel>` (trailing `/` ok) | JSON-RPC 2.0 for the workspace `<A2A base>/<rel>` |
335
+ | GET | `/a2a/<rel>/.well-known/agent-card.json` | The agent card. `Cache-Control: private, max-age=300` + `ETag` (304 on `If-None-Match`); invalid `.halo/agent-card.json` → 500 |
336
+ | POST | `/a2a-push/:pushId` | Webhook receiver for remotes this server dispatched to (`a2a_send`) |
337
+
338
+ **Exposure**: `<rel>` is the workspace path relative to the base dir (`HALO_A2A_ROOT`, default the user's home), each segment percent-encoded. Only a workspace containing `.halo/agent-card.json` is served; empty / `.` / `..` segments, separators inside a segment, and symlinks leaving the base are refused. The card's interface URL is `general.a2a.public_url` (env `HALO_A2A_PUBLIC_URL`) + `/a2a/<rel>/`, else derived from the request host.
339
+
340
+ **Auth** (RPC and card alike): a web-channel token in `Authorization: Bearer <t>` or `x-token: <t>` — headers only, `?token=` is ignored. Same `resolveTokenAuth` core and `web-token` lockout bucket as the [Web channel](#public-endpoints-token-auth-via-x-token-header-or-token-query). Missing token → 401 `{error: "token required"}` (before any path lookup); invalid token, unknown / unexposed path, or a token bound to another workspace → the same 404 `{error: "not found"}`; lockout → 429. The token's access level becomes the session's (`readonly` / `observer` → the A2A read-only profile). A token never reaches another workspace. A GET of anything but the card path → 404.
341
+
342
+ **JSON-RPC**: header `A2A-Version: 1.0` required (missing → treated as 0.3 → `-32009`). Body ≤16 MB; batches refused. Answered HTTP 200 with a JSON-RPC body — errors included; only the auth failures above use HTTP statuses.
343
+
344
+ | Method | Params (main) | Result |
345
+ |---|---|---|
346
+ | `SendMessage` | `message { role: "ROLE_USER", parts, messageId?, contextId?, taskId?, metadata?["halo/interrupt"] }`, `configuration { returnImmediately?, taskPushNotificationConfig? }` | `{ task }`. Blocks until terminal (≤10 min, then the WORKING task) unless `returnImmediately: true` |
347
+ | `SendStreamingMessage` | as SendMessage | SSE: `{task}` snapshot → `statusUpdate` (interims) / `artifactUpdate` (`progress` live text) → terminal `{task}`, then close; `: keepalive` every 15 s |
348
+ | `GetTask` | `id`, `historyLength?` (ignored) | Task: `{ id, contextId, status { state, message?, timestamp }, artifacts, metadata?["halo/errorKind"] }` |
349
+ | `ListTasks` | `contextId?`, `status?`, `statusTimestampAfter?` (ISO-8601), `pageSize?` (1–100, default 50), `pageToken?`, `includeArtifacts?` | `{ tasks, nextPageToken, pageSize, totalSize }`, newest `updated_at` first; `nextPageToken` `""` on the last page; artifacts only with `includeArtifacts`, file bytes never (counted in `metadata["halo/omittedFiles"]`) |
350
+ | `CancelTask` | `id` | The CANCELED task (already canceled → returned as is) |
351
+ | `SubscribeToTask` | `id` | SSE as above, for a live task |
352
+ | `CreateTaskPushNotificationConfig` | `taskId`, `url`, `id?`, `token?`, `authentication? { scheme, credentials }` | The config. URL policy applies |
353
+ | `GetTaskPushNotificationConfig` | `taskId`, `id` | The config |
354
+ | `ListTaskPushNotificationConfigs` | `taskId` | `{ configs, nextPageToken: "" }` |
355
+ | `DeleteTaskPushNotificationConfig` | `taskId`, `id` | `null` (idempotent) |
356
+ | `GetExtendedAgentCard` | — | `-32004` |
357
+
358
+ Parts: `{ text }`, and images `{ raw: <base64>, mediaType, filename? }` / `{ url, mediaType? }` — png / jpeg / gif / webp, ≤5 MB each, ≤10 MB per message. States: `TASK_STATE_WORKING` → `TASK_STATE_COMPLETED` (artifact `result`) / `TASK_STATE_FAILED` / `TASK_STATE_CANCELED` (artifact `partial` when there is output). A `contextId` is always server-minted (`a2a_<accountId>_…`, from an earlier response).
359
+
360
+ | Code | When |
361
+ |---|---|
362
+ | `-32700` | Body is not JSON |
363
+ | `-32600` | Not a JSON-RPC 2.0 request, a batch array, or a body over 16 MB |
364
+ | `-32601` | Unknown method |
365
+ | `-32602` | Invalid params: empty message, bad `role`, unknown or foreign `contextId`, `taskId` not in `contextId`, image fetch / limit failure, refused push URL (`push url not allowed: <reason>`), bad `pageSize` / `pageToken` / `status` / timestamp |
366
+ | `-32603` | Internal error; the workspace couldn't be opened |
367
+ | `-32001` | Task (or push config) not found — includes another account's task |
368
+ | `-32002` | CancelTask on a COMPLETED / FAILED task |
369
+ | `-32004` | Unsupported: `taskId` of a terminal task, SubscribeToTask / push-config create on a terminal task, `GetExtendedAgentCard` |
370
+ | `-32005` | A part that is neither text nor a supported image (incl. `data` parts) |
371
+ | `-32009` | `A2A-Version` missing or not `1.0` |
372
+
373
+ **Push delivery** (to a caller's webhook): `POST <url>` with `Content-Type: application/a2a+json`, `A2A-Version: 1.0`, `X-A2A-Notification-Token: <token>` when the config has one, `Authorization: <scheme> <credentials>` when it has `authentication`. Body `{ task }` on a terminal state, `{ statusUpdate }` for an interim. The pushed task carries no file bytes: text and status stay, file parts are counted in `metadata['halo/omittedFiles']`, and GetTask returns them. Each event is sent once per config; non-2xx retries with backoff (4xx other than 408 / 429 gives up at once). The URL must pass `general.a2a.url_allowlist`.
374
+
375
+ **`POST /a2a-push/:pushId`** (receiver): token in `X-A2A-Notification-Token` (or `Authorization: Bearer`), compared constant-time with the dispatch's; unknown id or wrong token → 404 `{error: "not found"}`. Body `{ task }` or `{ statusUpdate }` whose task id must belong to that dispatch (else 404 `{error: "unknown task"}`); invalid JSON → 400.
376
+
377
+ The push is a doorbell: only the task id is read from the body. The receiver then runs its own authed GetTask on the remote, with a 10 s deadline:
378
+ - **GetTask succeeds:** answers `{ ok: true }` and injects the fetched Task into the calling session asynchronously — the report when it is terminal, an interim when it is WORKING with a status message.
379
+ - **GetTask fails** (network, timeout, HTTP or JSON-RPC error): answers **503** `{error: "task fetch failed, retry later"}` and delivers nothing, so the sender retries.
380
+ - **Dispatch already reported:** answers `{ ok: true }` without a fetch.
381
+
318
382
  ## Show (world snapshot)
319
383
 
320
384
  File: `packages/server/src/routes/halo-city.ts`. Token auth (same `x-token` as
@@ -345,7 +409,7 @@ File: `packages/server/src/routes/agent-configs.ts`
345
409
  | GET | `/api/agent-configs/models` | Available model providers and models |
346
410
  | POST | `/api/agent-configs` | Create a new agent |
347
411
  | GET | `/api/agent-configs/:id/yaml?scope=&projectId=` | Read agent.yaml. `scope=workspace` without `projectId` → 400; `scope` other than `global` / `workspace` (omitted = `global`) → 400 `Invalid scope` |
348
- | PUT | `/api/agent-configs/:id/yaml?scope=&projectId=` | Write agent.yaml. Same 400s as GET |
412
+ | PUT | `/api/agent-configs/:id/yaml?scope=&projectId=` | Write agent.yaml. Same 400s as GET, plus 400 `context.maxTokens <n> exceeds the max context of <model> (<max>)` when the window is above the model's `maxContextWindow` — unless the model and the value are both unchanged from the file on disk (older scaffolds wrote 272000 for every model; those agents stay editable) |
349
413
  | DELETE | `/api/agent-configs/:id?scope=&projectId=` | Delete agent (global has "last one" protection). Same 400s as GET — never falls back to the same-named global agent or skips the last-one guard |
350
414
  | PATCH | `/api/agent-configs/:id/toggle?scope=&projectId=` | Toggle disabled in workspace DB. `projectId` required. Returns `{ ok, disabled }`. |
351
415
  | GET | `/api/agent-configs/:id/md/:fileType` | Read an MD file (AGENT.md / INSTRUCTIONS.md / INDEX.md) |
@@ -365,10 +429,10 @@ File: `packages/server/src/routes/skills.ts`
365
429
 
366
430
  | Method | Path | Purpose |
367
431
  |---|---|---|
368
- | GET | `/api/skills?projectId=` | List skills (global + workspace unmerged — two distinct entries). Global entries shadowed by a same-id workspace skill are marked `overridden: true`. Runtime override happens in `agent-loader.ts`, not in this route. |
369
- | POST | `/api/skills` | Create a skill directory + SKILL.md |
370
- | DELETE | `/api/skills/:id?scope=&projectId=` | Delete skill directory + settings entry |
371
- | PATCH | `/api/skills/:id/toggle?scope=&projectId=` | Toggle disabled in workspace DB. `projectId` required. Returns `{ ok, disabled }`. |
432
+ | GET | `/api/skills?projectId=` | List skills (global + workspace + extension unmerged — distinct entries). An item shipped by an installed extension has `scope: 'extension'` plus `extId` (the owning extension id). An entry shadowed at runtime by a same-id higher-precedence skill (workspace > global > extension) is marked `overridden: true`. Runtime override happens in `agent-loader.ts`, not in this route. |
433
+ | POST | `/api/skills` | Create a skill directory + SKILL.md. `scope: 'extension'` → 403 (extension skills are read-only) |
434
+ | DELETE | `/api/skills/:id?scope=&projectId=` | Delete skill directory + settings entry. `scope=extension` → 403 (read-only) |
435
+ | PATCH | `/api/skills/:id/toggle?scope=&projectId=` | Toggle disabled in workspace DB. `projectId` required; `scope` is `global` / `workspace` / `extension` (an extension skill's disable key is `extension:<id>` — a workspace setting, nothing written into the extension's folder). Returns `{ ok, disabled }`. |
372
436
 
373
437
  ## Commands
374
438
 
@@ -402,6 +466,13 @@ PATCH / DELETE walk the dotted key layer by layer, so a segment of `__proto__` /
402
466
 
403
467
  All four are `globalOnly`, take effect on restart. See [design/observability.md](../design/observability.md).
404
468
 
469
+ ### Notebook kernel settings
470
+
471
+ - `general.jupyter.enabled` — allow notebook execution through the `/api/jupyter/*` proxy, default `true`
472
+ - `general.jupyter.path` — full path of the `jupyter` executable; empty (default) = `jupyter` on PATH
473
+
474
+ Both `globalOnly`, read at each server spawn / status probe — no restart. See [design/canvas-extensions.md](../design/canvas-extensions.md#notebook-kernels-jupyter-proxy).
475
+
405
476
  ## Metrics
406
477
 
407
478
  File: `packages/server/src/routes/metrics.ts`.
@@ -427,9 +498,17 @@ File: `packages/server/src/routes/evolution.ts`. Surfaces the global `evolution_
427
498
  | DELETE | `/api/evolution/runs/:id` | Delete a finished run: its on-disk artifacts (run dir + archive zip + wrapper log) **and** the DB row, plus every apply that references it (artifacts + row, `evolution:apply_changed` `kind:'deleted'`). Rejected with 409 for in-flight states (`pending` / `running` / `approved`) so a live wrapper / queued apply isn't pulled out from under. Broadcasts `evolution:run_changed` with `kind:'deleted'`. |
428
499
  | GET | `/api/evolution/applies` | List in-flight apply rows (`pending` / `running` / `syncing`, newest 200; used for status badges). Returns `{applies}`. |
429
500
 
430
- ## AgentCore adapter (runtime-mode only)
501
+ ## AgentCore runtime container (`halo agentcore` only)
502
+
503
+ File: `packages/server/src/a2a/agentcore.ts`. Served **only** when `HALO_RUNTIME_MODE=agentcore-a2a` (set by `halo agentcore`; see [design/agentcore.md](../design/agentcore.md)), on port 9000, with no cookie / token auth — AgentCore verifies the caller's signed request before forwarding. Not present in normal server mode.
504
+
505
+ | Method | Path | Purpose |
506
+ |---|---|---|
507
+ | GET | `/ping` | `{"status":"HealthyBusy"}` while the workspace lease is held and a session is running or a push is pending, else `{"status":"Healthy"}` |
508
+ | POST | `/` | A2A JSON-RPC. Without the workspace lease: error `-32603` "workspace is in use by another runtime session…" |
509
+ | GET | `/.well-known/agent-card.json` | The agent card (`<ws>/.halo/agent-card.json`, seeded if missing); interface URL = `HALO_A2A_PUBLIC_URL` + `/`; no `securitySchemes` |
431
510
 
432
- File: `packages/server/src/routes/agentcore.ts`. Mounted **only** when `HALO_RUNTIME_MODE=agentcore` (see [design/agentcore.md](../design/agentcore.md)): `GET /ping` (health, `HealthyBusy` while any agent session runs), `POST /invocations` (prompt in / reply out), and streaming `WS /ws`. Not present in normal server mode.
511
+ Any other path is 404. The former HTTP-protocol routes (`POST /invocations`, WS `/ws`) were removed 2026-10-09.
433
512
 
434
513
  ---
435
514
 
@@ -14,6 +14,7 @@ For this host's `/usr` installation and `ubuntu` service user, after authorizati
14
14
  (
15
15
  set -e
16
16
  version='x.y.z' # exact requested version
17
+ curl -sfI "https://registry.npmjs.org/@turmind/halo/-/halo-$version.tgz" >/dev/null # tarball replicated? (a fresh publish can 404 for ~15 min)
17
18
  sudo sh -c 'umask 022; npm install -g --prefix /usr --no-audit --no-fund "@turmind/halo@$1"' sh "$version"
18
19
  installed=$(sudo -u ubuntu env HOME=/home/ubuntu /usr/bin/halo --version 2>&1)
19
20
  test "$installed" = "halo $version" # CLI writes to stderr; mismatch or launch failure stops before restart
@@ -23,6 +24,8 @@ sudo journalctl -u halo.service --since '2 minutes ago' -n 40 --no-pager
23
24
  )
24
25
  ```
25
26
 
27
+ **Fresh-publish gate:** right after `npm publish`, `npm view` may already show the version while the tarball still 404s for several minutes (1.6.0: ~15 min, the first prod install failed `E404` before restart). The `curl -sfI` line stops the sequence before install; wait and rerun, never republish.
28
+
26
29
  **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
30
 
28
31
  **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.
@@ -63,7 +66,7 @@ pnpm --filter @turmind/halo-admin build # next build + copy-monaco; never a ba
63
66
 
64
67
  ## 2. Runtime data locations
65
68
 
66
- No directory needs to be created by hand. SQLite databases are created automatically on first use: per-workspace state at `<workspace>/.halo/halo.db`, plus global queues at `~/.halo/global/evo.db`, `~/.halo/global/cron.db` and `~/.halo/global/runs.db`, and channel accounts at `~/.halo/secrets/channels/channels.db`.
69
+ No directory needs to be created by hand. SQLite databases are created automatically on first use: per-workspace state at `<workspace>/.halo/halo.db`, plus global queues at `~/.halo/global/evo.db`, `~/.halo/global/cron.db`, `~/.halo/global/runs.db` and `~/.halo/global/a2a.db`, and channel accounts at `~/.halo/secrets/channels/channels.db`.
67
70
 
68
71
  ## 3. Run `halo setup`
69
72
 
@@ -220,6 +223,7 @@ This installs the `halo` binary on `$PATH`. Subcommands available:
220
223
  | `halo cli "<prompt>"` | One-shot prompt → reply, exit |
221
224
  | `halo agents` / `halo sessions` | List agents / sessions |
222
225
  | `halo acp` | Stdio bridge that lets an ACP client (e.g. Zed / JetBrains) drive a halo server — see [acp-adapter.md](acp-adapter.md) |
226
+ | `halo agentcore --workspace <path>` | Foreground A2A server for an Amazon Bedrock AgentCore Runtime container — see [design/agentcore.md](../design/agentcore.md) |
223
227
 
224
228
  ### Upgrade flow
225
229
 
@@ -405,17 +405,23 @@ Still good practice: build server + admin **before** `pnpm dist:arm64`.
405
405
  confirm does nothing, because `if (!confirm(...)) return` always returns). In
406
406
  a plain browser the helpers fall back to the native sync dialogs.
407
407
 
408
+ - **macOS notifications need a code-signed app (Electron 42+).** Electron moved
409
+ to `UNNotification`, which shows nothing for an unsigned app; our dmg is
410
+ ad-hoc (`identity: null`), so `Notification` emits `failed` (logged as
411
+ `notification failed: …` in `desktop.log`) and only the Dock bounce remains.
412
+ The fix is a Developer ID signature, not code.
413
+
408
414
  - **Media capture for extensions (`media` capability, e.g. htrans) —
409
415
  ⚠ not yet verified on a real Mac.** Three pieces, all needed:
410
416
  - **Info.plist usage strings** (`electron-builder.yml` → `mac.extendInfo`):
411
417
  `NSCameraUsageDescription`, `NSMicrophoneUsageDescription`, and
412
418
  `NSAudioCaptureUsageDescription` (system-audio capture; required on macOS
413
419
  14.2+). Missing ones make macOS deny / kill the request silently.
414
- - **`--enable-features=MacLoopbackAudioForScreenShare,MacSckSystemAudioLoopbackOverride`**
415
- on macOS (`main.cjs`, appended before app `ready`, merged with any existing
416
- `enable-features` value) — Chromium's loopback (system-audio) capture is
417
- behind these flags; macOS 13+ (Darwin 22+) only, so older macOS never
418
- offers or passes `audio:'loopback'`.
420
+ - **No feature switch for system audio** (Electron 39+): loopback capture is
421
+ built in (CoreAudio Tap on macOS 14.2+, ScreenCaptureKit below). The old
422
+ `MacLoopbackAudioForScreenShare` / `MacSckSystemAudioLoopbackOverride`
423
+ switches were removed with the Electron 44 upgrade; the plist string above
424
+ is what still matters.
419
425
  - **`getDisplayMedia()` picker**: Electron has no built-in one (the call just
420
426
  fails), so `session.setDisplayMediaRequestHandler` (mac / win only; Linux
421
427
  keeps Electron's default) lists `desktopCapturer` sources and asks the
@@ -67,7 +67,7 @@ Your deployment scripts should also prepend this line.
67
67
 
68
68
  ## Runtime directories
69
69
 
70
- - SQLite: `<workspace>/.halo/halo.db` (per-workspace, auto-created on first use) + global queues `~/.halo/global/evo.db`, `~/.halo/global/cron.db` and `~/.halo/global/runs.db` + channel accounts `~/.halo/secrets/channels/channels.db`
70
+ - SQLite: `<workspace>/.halo/halo.db` (per-workspace, auto-created on first use) + global queues `~/.halo/global/evo.db`, `~/.halo/global/cron.db`, `~/.halo/global/runs.db` and `~/.halo/global/a2a.db` + channel accounts `~/.halo/secrets/channels/channels.db`
71
71
  - Session files: `.halo/sessions/{agentId}/{sessionId}.json`
72
72
  - Global config: `~/.halo/global/`
73
73
  - Per-project config: `<workspace>/.halo/`
@@ -113,8 +113,11 @@ Source: `packages/server/src/config.ts`
113
113
  | `HALO_LOG_LEVEL` | `warn` | `settings.yaml general.logging.level` | Log level: debug \| info \| warn \| error |
114
114
  | `HALO_JWT_SECRET` | (none) | `config.yaml server.jwt_secret` | JWT signing secret (generated by `halo setup` if missing; stripped from child-process env) |
115
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). |
116
- | `HALO_RUNTIME_MODE` | (none) | — | `agentcore` runs the server as an Amazon Bedrock AgentCore Runtime container (see [design/agentcore.md](../design/agentcore.md)) |
117
- | `HALO_WORKSPACE` | `process.cwd()` | — | Workspace root in agentcore mode |
116
+ | `HALO_RUNTIME_MODE` | (none) | — | `agentcore-a2a` runs the server as an Amazon Bedrock AgentCore Runtime container speaking A2A — set by `halo agentcore`, not by hand (see [design/agentcore.md](../design/agentcore.md)) |
117
+ | `HALO_WORKSPACE` | `process.cwd()` | — | The one workspace served in agentcore-a2a mode (`halo agentcore -w` overrides it; required there) |
118
+ | `HALO_A2A_ROOT` | the user's home | — | Base dir A2A exposes workspaces under: a workspace `<base>/A/B` with `.halo/agent-card.json` is served at `/a2a/A/B` (see [design/a2a.md](../design/a2a.md)); workspaces outside it can't be exposed |
119
+ | `HALO_A2A_PUBLIC_URL` | (none) | `settings.yaml general.a2a.public_url` | This server's A2A origin as peers see it (no trailing slash), used for the agent card's interface URL and as the default push-webhook base. In agentcore-a2a mode: the runtime's invoke URL (`…/runtimes/<escaped ARN>/invocations`) |
120
+ | `HALO_A2A_ACCESS` | `workspace` | — | agentcore-a2a mode only: access level of every A2A session — `workspace` \| `full` \| `readonly`; anything else is a boot error |
118
121
  | `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 |
119
122
  | `HALO_CLI` | `halo` (`halo.cmd` on Windows) | — | `halo` cli executable that cron runs and the evolution wrapper spawn — a dev override |
120
123
  | `HALO_VERSION` | `dev` | — | Version string reported by `GET /api/health` and the cli; stamped by the bundle / desktop launcher, so normally not set by hand |
@@ -127,12 +130,13 @@ settings.yaml only (no env override):
127
130
  - `general.compact.keep_messages` (default 5) — recent messages to keep uncompacted, restart required
128
131
  - `general.compact.max_summary_input` (default 15000) — local compaction fallback input cap, restart required
129
132
  - `general.compact.max_message_slice` (default 800) — local compaction per-message cap, restart required
133
+ - `general.a2a.url_allowlist` (default `100.64.0.0/10,*.ts.net`) — comma-separated CIDRs / host patterns A2A egress may reach besides public https (push webhooks, remote cards and RPC, inbound image URLs); private / loopback ranges only when listed, plain http only to listed hosts / ranges, link-local always refused; scope: global. See [design/a2a.md](../design/a2a.md#push-delivery-and-url-policy)
130
134
  - `general.server.trust_proxy` (default `false`) — trust `x-forwarded-for` for client IP resolution, scope: global (enable only behind a reverse proxy you control)
131
135
  - `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
132
136
  - `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
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
137
+ - `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,a2a}.db` + their `-wal`/`-shm` files); Full sessions, the admin file explorer and the terminal are not affected, scope: global
134
138
  - `general.logging.level` (default `warn`) — log level: debug | info | warn | error, restart required
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)
139
+ - `general.observability.endpoint` (default `''`) — OTLP collector base URL (e.g. `http://localhost:4318`); empty = off, scope: global, restart required (the server; `halo cli` / `halo tui` read all `general.observability.*` keys at start, so cron and evolution runs pick a change up on their next run). See [design/observability.md](../design/observability.md)
136
140
  - `general.observability.service_name` (default `halo`) — OTel resource `service.name`, scope: global, restart required
137
141
  - `general.observability.headers` (default `''`, secret) — extra OTLP request headers, comma-separated `k=v`, scope: global, restart required
138
142
  - `general.observability.capture_content` (default `false`) — put prompt/completion/tool text on spans, scope: global, restart required
@@ -25,6 +25,8 @@ previews/
25
25
  │ │ for previews that parse server-side instead of raw bytes
26
26
  │ ├── data-table.tsx Shared table renderer (headers + rows + prev/next pager) for
27
27
  │ │ server-parsed previews (parquet / sqlite / csv)
28
+ │ ├── image-annotator.tsx ImageAnnotator: drawing board over an image → `halo:attach-image`
29
+ │ ├── annotate.ts Its pure parts: coordinate mapping, strokes reducer, composite
28
30
  │ └── print.ts Pop-up print helper
29
31
  ├── workers/ Parse workers (one per heavy format)
30
32
  │ ├── worker-client.ts Generic WorkerClient<T> class — id-routed postMessage
@@ -68,9 +70,15 @@ interface PreviewProps {
68
70
  onOpenAsText?: () => void // set when the file can also be force-opened as text
69
71
  tooLarge?: boolean // file over the editor's 10MB read cap; FilePreview shows a placeholder instead of the plugin
70
72
  size?: number // on-disk size in bytes, shown by that placeholder
73
+ mtime?: number // on-disk mtime the buffer knows — see Live refresh below
74
+ bundle?: boolean // `path` is a bundle directory: only bundle extensions are candidates
71
75
  }
72
76
  ```
73
77
 
78
+ ### Live refresh
79
+
80
+ When the open file changes on disk (mtime strictly newer than the buffer's), `FilePreview` remounts a built-in plugin — `key={mtime}`, with `viewUrl` cache-busted (`&v=<mtime>`, `versionedUrl()` in `features/editor/file-refresh.ts`). A plugin needs no code for this, but **all its state resets**: fetched data, zoom, page, selection. Keep anything that must survive outside the component. Extensions aren't remounted — the host re-fetches the file and hands it to the running iframe (skipped while the document is dirty); bundles are left alone.
81
+
74
82
  ## Adding a new file type
75
83
 
76
84
  ### 1. Create the view component
@@ -171,6 +179,24 @@ Plugin-specific buttons use the shared `<ToolbarButton>`:
171
179
  </ToolbarButton>
172
180
  ```
173
181
 
182
+ ## Reusable: `ImageAnnotator` + `halo:attach-image`
183
+
184
+ `ui/image-annotator.tsx` is the board behind the image preview's **Annotate** button (media-view is the only user today); any preview that shows a picture can mount it.
185
+
186
+ ```tsx
187
+ <ImageAnnotator
188
+ src={viewUrl} // same URL as the <img>
189
+ name={name} // → `<name>-annotated.png`
190
+ viewStyle={{ transform }} // the picture's own zoom / pan transform, so marks line up
191
+ panning={dragging} // optional: grabbing cursor while the host pans
192
+ onClose={stopAnnotating} // Esc, ✕, and after a successful Send
193
+ />
194
+ ```
195
+
196
+ - Strokes are kept in natural image pixels, mapped through the board's `getBoundingClientRect()`, so any CSS transform on `viewStyle` works. Left / touch drag draws (and swallows the event); everything else — wheel, middle-drag — is left to the host.
197
+ - Send composites image + marks on a canvas (long edge ≤ 4096, `COMPOSITE_MAX_EDGE` in `ui/annotate.ts`) and fires `window.dispatchEvent(new CustomEvent('halo:attach-image', { detail: { file: File } }))`. The chat input (`features/chat/message-input.tsx`) listens: vision model → normal attachment path; text-only → the `chat.attachNoVision` notice; then it shows the chat and focuses the input. Any other feature can hand an image to chat through the same event.
198
+ - Mount it only where the chat is reachable: media-view gates it to the main editor store (Skills / Agents editors scope their own and hide the chat) and skips SVG.
199
+
174
200
  ## Workers — parsing off the main thread
175
201
 
176
202
  If your format needs CPU-heavy parsing (non-trivial ArrayBuffer → structured data), run it in a Web Worker so the UI stays responsive even with several tabs parsing concurrently.