@turmind/halo 1.6.0 → 1.6.2

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 (59) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/58.d26b1c4f14404226.js +1 -0
  3. package/admin-out/_next/static/chunks/989-ddba0eb72566da23.js +1 -0
  4. package/admin-out/_next/static/chunks/app/page-1d27029caba48956.js +1 -0
  5. package/admin-out/_next/static/chunks/webpack-74f577c1b094f942.js +1 -0
  6. package/admin-out/_next/static/css/3b4dcd0ac6b8eae8.css +1 -0
  7. package/admin-out/index.html +1 -1
  8. package/admin-out/index.txt +5 -5
  9. package/bundled-docs/design/agent.md +6 -4
  10. package/bundled-docs/design/canvas-extensions.md +30 -4
  11. package/bundled-docs/design/cron.md +24 -11
  12. package/bundled-docs/design/prompt-system.md +10 -5
  13. package/bundled-docs/design/session.md +4 -1
  14. package/bundled-docs/design/storage.md +36 -11
  15. package/bundled-docs/design/ws.md +7 -5
  16. package/bundled-docs/dev/add-model-provider.md +17 -11
  17. package/bundled-docs/dev/api.md +25 -12
  18. package/bundled-docs/dev/deploy.md +3 -0
  19. package/bundled-docs/dev/desktop-packaging.md +11 -5
  20. package/bundled-docs/dev/env.md +1 -1
  21. package/bundled-docs/dev/previews.md +26 -0
  22. package/bundled-docs/guide/agents.md +6 -6
  23. package/bundled-docs/guide/cli.md +5 -4
  24. package/bundled-docs/guide/skills.md +12 -6
  25. package/bundled-docs/guide/workspace.md +1 -1
  26. package/bundled-docs/requirements/agent.md +5 -0
  27. package/bundled-docs/requirements/chat.md +9 -5
  28. package/bundled-docs/requirements/command.md +4 -0
  29. package/bundled-docs/requirements/editor.md +20 -6
  30. package/bundled-docs/requirements/session.md +1 -1
  31. package/bundled-docs/requirements/skill.md +15 -9
  32. package/dist/index.js +2332 -1348
  33. package/package.json +1 -1
  34. package/templates/models/anthropic.yaml +109 -13
  35. package/templates/models/aws-bedrock-claude-invoke.yaml +39 -13
  36. package/templates/models/aws-bedrock-mantle.yaml +60 -28
  37. package/templates/models/aws-bedrock-openai.yaml +52 -29
  38. package/templates/models/deepseek.yaml +19 -10
  39. package/templates/models/doubao.yaml +72 -20
  40. package/templates/models/hunyuan.yaml +79 -15
  41. package/templates/models/kimi.yaml +63 -3
  42. package/templates/models/mimo-token-plan-china.yaml +9 -1
  43. package/templates/models/minimax.yaml +9 -3
  44. package/templates/models/openai.yaml +120 -17
  45. package/templates/models/qwen.yaml +103 -6
  46. package/templates/models/zhipu.yaml +9 -1
  47. package/templates/schema.sql +1 -1
  48. package/templates/skills/cron/SKILL.md +40 -3
  49. package/templates/skills/cron/manage-cron.py +140 -15
  50. package/templates/skills/skill/SKILL.md +22 -11
  51. package/templates/skills/workspace/share.md +5 -2
  52. package/templates/skills/workspace/stage.py +51 -14
  53. package/admin-out/_next/static/chunks/220.7566bc89f3d98993.js +0 -1
  54. package/admin-out/_next/static/chunks/989-a7f752480eaaa2dc.js +0 -1
  55. package/admin-out/_next/static/chunks/app/page-8173112f256bc483.js +0 -1
  56. package/admin-out/_next/static/chunks/webpack-dc73047e0279979b.js +0 -1
  57. package/admin-out/_next/static/css/981c9e5610ce535a.css +0 -1
  58. /package/admin-out/_next/static/{_F_Fkyh9PainUNY82EEjY → VgAe62AuGEiYrF4JQ7WQe}/_buildManifest.js +0 -0
  59. /package/admin-out/_next/static/{_F_Fkyh9PainUNY82EEjY → VgAe62AuGEiYrF4JQ7WQe}/_ssgManifest.js +0 -0
@@ -95,6 +95,12 @@ Installed extensions are directories under `~/.halo/global/extensions/<id>/` wit
95
95
  | DELETE | `/api/extensions/:id` | cookie | Remove the directory. 400 for an invalid id shape, 404 if not installed. Returns `{ok, id}` |
96
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) |
97
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) |
98
104
 
99
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).
100
106
 
@@ -117,7 +123,7 @@ Unified session log API — list + read session files across all agents.
117
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 |
118
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 |
119
125
 
120
- 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`.
121
127
 
122
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.
123
129
 
@@ -208,9 +214,9 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
208
214
 
209
215
  | Method | Path | Purpose |
210
216
  |---|---|---|
211
- | 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) |
212
- | 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}` |
213
- | 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}` |
214
220
  | DELETE | `/api/cron/jobs/:id` | Unschedule, delete every `cron_runs` row for the job, then delete the job. Returns `{ok}` |
215
221
  | POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget). Returns `{ok}` |
216
222
  | POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
@@ -221,7 +227,7 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
221
227
 
222
228
  | Method | Path | Purpose |
223
229
  |---|---|---|
224
- | 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) |
225
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) |
226
232
 
227
233
  ### Reference data (for the create form)
@@ -403,7 +409,7 @@ File: `packages/server/src/routes/agent-configs.ts`
403
409
  | GET | `/api/agent-configs/models` | Available model providers and models |
404
410
  | POST | `/api/agent-configs` | Create a new agent |
405
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` |
406
- | 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) |
407
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 |
408
414
  | PATCH | `/api/agent-configs/:id/toggle?scope=&projectId=` | Toggle disabled in workspace DB. `projectId` required. Returns `{ ok, disabled }`. |
409
415
  | GET | `/api/agent-configs/:id/md/:fileType` | Read an MD file (AGENT.md / INSTRUCTIONS.md / INDEX.md) |
@@ -423,10 +429,10 @@ File: `packages/server/src/routes/skills.ts`
423
429
 
424
430
  | Method | Path | Purpose |
425
431
  |---|---|---|
426
- | 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. |
427
- | POST | `/api/skills` | Create a skill directory + SKILL.md |
428
- | DELETE | `/api/skills/:id?scope=&projectId=` | Delete skill directory + settings entry |
429
- | 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 }`. |
430
436
 
431
437
  ## Commands
432
438
 
@@ -460,6 +466,13 @@ PATCH / DELETE walk the dotted key layer by layer, so a segment of `__proto__` /
460
466
 
461
467
  All four are `globalOnly`, take effect on restart. See [design/observability.md](../design/observability.md).
462
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
+
463
476
  ## Metrics
464
477
 
465
478
  File: `packages/server/src/routes/metrics.ts`.
@@ -968,8 +981,8 @@ It does not remove session files. The full delete (rows + JSON + archive segment
968
981
 
969
982
  | Type | Fields | Purpose |
970
983
  |---|---|---|
971
- | `chat:stream` | `text`, `agentName`, `turnId`, `taskId?` | Text chunk from the agent |
972
- | `chat:thinking` | `text`, `agentName`, `turnId`, `taskId?` | Thinking chunk |
984
+ | `chat:stream` | `text`, `agentName`, `turnId` | Text chunk from the root agent (a sub-agent's text isn't sent on this wire — see [design/ws.md](../design/ws.md#agent-event--ws-message-mapping)) |
985
+ | `chat:thinking` | `text`, `agentName`, `turnId` | Thinking chunk (root only, as above) |
973
986
  | `agent:tool_call` | `tool`, `toolUseId`, `input`, `agentName`, `turnId`, `taskId?` | Tool invocation card |
974
987
  | `agent:tool_result` | `result`, `toolUseId`, `agentName`, `durationMs`, `taskId?` | Tool result |
975
988
  | `chat:usage` | `contextTokens`, `outputTokens`, `usage`, `modelId`, `turnId` | Token accounting per model call (root only) |
@@ -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.
@@ -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
@@ -136,7 +136,7 @@ settings.yaml only (no env override):
136
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
137
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
138
138
  - `general.logging.level` (default `warn`) — log level: debug | info | warn | error, restart required
139
- - `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)
140
140
  - `general.observability.service_name` (default `halo`) — OTel resource `service.name`, scope: global, restart required
141
141
  - `general.observability.headers` (default `''`, secret) — extra OTLP request headers, comma-separated `k=v`, scope: global, restart required
142
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.
@@ -44,7 +44,7 @@ Data-driven form for name / description / model / tools / skills / thinking. Cha
44
44
  **Model section** has four fields:
45
45
  - `provider` — dropdown populated from `~/.halo/global/models/*.yaml` plus hub-installed `~/.halo/global/models.d/*.yaml` (`/extension models`; updates the list live)
46
46
  - `id` — combobox (input + datalist); preset options filtered by current provider, also accepts manual input of any model ID
47
- - `endpoint` — combobox (input + datalist); full endpoint URL (e.g. `https://bedrock-runtime.ap-northeast-1.amazonaws.com`), supports custom proxy URLs
47
+ - `endpoint` — combobox (input + datalist); full endpoint URL (e.g. `https://bedrock-runtime.ap-northeast-1.amazonaws.com`), supports custom proxy URLs. When a model is only served in some regions the list narrows to those, switching to it moves the endpoint there, and an endpoint off its list shows a warning
48
48
  - `maxTokens` — optional; defaults to `maxOutputTokens` in the provider yaml
49
49
 
50
50
  Switching provider resets the model id to the new provider's default model (its `defaultModelId`), keeping the current id when the new provider lists it too.
@@ -141,8 +141,8 @@ priority: 0
141
141
 
142
142
  model:
143
143
  provider: aws-bedrock-claude-invoke # required, matches ~/.halo/global/models/<provider>.yaml
144
- id: global.anthropic.claude-sonnet-4-6 # the provider yaml's defaultModelId
145
- endpoint: https://bedrock-runtime.us-west-2.amazonaws.com # full endpoint URL; supports custom proxy
144
+ id: global.anthropic.claude-opus-5-5 # any model id the provider lists (or a custom one)
145
+ endpoint: https://bedrock-runtime.us-east-1.amazonaws.com # full endpoint URL; supports custom proxy
146
146
  maxTokens: 16384 # optional, max output tokens (default from provider yaml)
147
147
 
148
148
  # Prompt caching — lower cost on repeated system prompts
@@ -161,8 +161,8 @@ system_prompt: |
161
161
 
162
162
  # Context window
163
163
  context:
164
- maxTokens: 272000 # max context (default 272000)
165
- compressAt: 0.9 # auto-compact trigger (0.9 = compact when 90% full)
164
+ maxTokens: 272000 # context budget — new agents get the model's recommended window; can't exceed the model's max
165
+ compressAt: 0.9 # auto-compact trigger (0.9 = compact when 90% full); new agents get your global setting, else the model's
166
166
 
167
167
  # Tool allowlist (strict by name; unlisted tools are not injected).
168
168
  # Session/delegation tools are NOT listed here — see `team` below.
@@ -191,7 +191,7 @@ team:
191
191
  **Full tool list**: see [dev/tools.md](../dev/tools.md).
192
192
 
193
193
  **Common edits**:
194
- - Agent handling long tasks → `context.maxTokens: 500000` + `context.compressAt: 0.85`
194
+ - Agent handling long tasks → raise `context.maxTokens` up to the model's max (the form shows it, e.g. 1M for Opus 5.5) + `context.compressAt: 0.85` — on most 1M-class models input past 272K bills at long-context rates
195
195
  - Save cost → `model.promptCaching: '1h'`
196
196
  - Complex reasoning → `thinking.enabled: true` + `thinking.effort: high`
197
197
  - Read-only agent → keep only `file_read` / `file_list` / `grep` / `glob` in `tools`
@@ -64,8 +64,9 @@ To list agents or sessions without running a prompt, use the dedicated subcomman
64
64
  | Flag | Short | Default | Description |
65
65
  |---|---|---|---|
66
66
  | `--workspace` | `-w` | cwd | Workspace path |
67
- | `--agent` | `-a` | `default` | Agent ID |
68
- | `--session` | `-s` | auto | Resume session by ID |
67
+ | `--dir` | | root | Focus the session on a workspace sub-folder — same as a sub-agent's `working_dir`: the folder's `.halo/INSTRUCTIONS.md` chain joins the system prompt, plus a `Working directory:` line. Workspace-relative or absolute; must exist inside the workspace. On a resumed session it re-points the stored folder in place (one `[cli] session <id>: dir <from> → <to>` line on stderr); `--dir .` moves it back to the root |
68
+ | `--agent` | `-a` | `default` | Agent ID. A session's agent never changes: `-s <existing>` with a different `-a` is refused (`session <id> runs agent <x>; omit -a to resume it, or use -n …`); without `-s`, the latest cli session is only resumed when it runs the `-a` agent — otherwise a new session starts |
69
+ | `--session` | `-s` | auto | Resume session by ID; created with that id when it doesn't exist |
69
70
  | `--new` | `-n` | off | Always start a new session |
70
71
  | `--format` | `-f` | `text` | Output format: `text` or `json` |
71
72
  | `--verbose` | `-v` | off | Show thinking, tool calls, usage on stderr |
@@ -129,7 +130,7 @@ Multi-turn conversation. Supports all standard Halo slash commands:
129
130
  | Command | Description |
130
131
  |---|---|
131
132
  | `/help` | Show available commands |
132
- | `/session new` | Start a new session |
133
+ | `/session new` | Start a new session — it keeps the current session's folder (`--dir`; from a sub-agent, its root's), or starts at the root if that folder is gone |
133
134
  | `/session list` | List recent sessions |
134
135
  | `/session switch <n>` | Switch to session by number |
135
136
  | `/session stop` | Stop current agent task (ends the turn, no re-run) |
@@ -227,7 +228,7 @@ halo models install <dir> [--yes] # install <dir>/*.yaml into ~/.halo/global/m
227
228
  halo models list # id · copy in effect (bundled | hub) · revision · runtime
228
229
  ```
229
230
 
230
- Normally run for you by `/extension models`, which downloads the hub's newest `models-v*` release (https GitHub / Gitea / Forgejo / GitLab only) and passes the unpacked directory to `halo models install`. Per provider id the copy with the higher `revision` is in effect (tie → the `models.d` copy); an older one is skipped, an identical one reported up to date. Refused per provider: a `runtime:` this halo doesn't know (needs a newer halo), no `revision`, a `secrets[].default` that isn't empty or `<<ENV_NAME>>`. If any provider is new or changes `defaultEndpoint` / `endpointPresets`, the changes are listed, **nothing is written**, and the exit code is `3` — re-run with `--yes` to apply. Writes are atomic (tmp + rename); a running server reloads its registry from `models.d/` without a restart and pushes the change to open admin pages. Existing agents keep the model / endpoint in their own `agent.yaml`. Exit codes: `0` ok (refusals listed in the summary line), `1` error, `3` needs `--yes`. To undo, delete the file from `~/.halo/global/models.d/`.
231
+ Normally run for you by `/extension models`, which downloads the hub's newest `models-v*` release (https GitHub / Gitea / Forgejo / GitLab only) and passes the unpacked directory to `halo models install`. Per provider id the copy with the higher `revision` is in effect (tie → the `models.d` copy); an older one is skipped, an identical one reported up to date. Refused per provider: a `runtime:` this halo doesn't know (needs a newer halo), no `revision`, a `secrets[].default` that isn't empty or `<<ENV_NAME>>`. Endpoint changes against the copy in effect are always listed. Consent is needed for any URL the server would now send requests (and credentials) to that it didn't before: a new provider, a changed `defaultEndpoint` / `endpointPresets`, or a URL added to a model's `endpoints:` list that the provider doesn't already use anywhere. A model list that only loses URLs, or gains one already used elsewhere in the provider, is listed but needs no consent; a reorder alone is not a change. When consent is needed, **nothing is written** and the exit code is `3` — re-run with `--yes` to apply. Writes are atomic (tmp + rename); a running server reloads its registry from `models.d/` without a restart and pushes the change to open admin pages. Existing agents keep the model / endpoint in their own `agent.yaml`. Exit codes: `0` ok (refusals listed in the summary line), `1` error, `3` needs `--yes`. To undo, delete the file from `~/.halo/global/models.d/`.
231
232
 
232
233
  ## Server Mode (background daemon)
233
234
 
@@ -26,9 +26,15 @@ Putting those in AGENT.md would bloat the system prompt. Skills use **progressiv
26
26
  deploy-check/
27
27
  SKILL.md
28
28
  scripts/verify.sh
29
+
30
+ ~/.halo/global/extensions/<ext>/skills/ # Shipped by an installed extension (read-only)
31
+ <ext>-helper/
32
+ SKILL.md
29
33
  ```
30
34
 
31
- Workspace overrides global (same id).
35
+ Precedence for the same id: workspace > global > extension. The higher folder wins wholly.
36
+
37
+ **Extension skills** come with an installed canvas extension (see [Canvas preview extensions](../design/canvas-extensions.md#extension-skills)). They appear in the Skills panel under **Extensions** ("from extension X"), read-only: no edit, no delete. They are not enabled automatically: tick them for an agent like any other skill. Uninstalling the extension removes them. To customise one, copy its folder into `~/.halo/global/skills/<id>/` or `<project>/.halo/skills/<id>/` and edit the copy, which then takes precedence.
32
38
 
33
39
  **Built-in skills** (`agent`, `skill`, `workspace`, `cron`, `send-file`, `self`, `aws-knowledge`, `web-search`, `halo`, `extension`) are server-shipped and **force-overwritten on every server startup**. Local edits to these directories under `~/.halo/global/skills/` will be lost. To customize one, copy it into `<project>/.halo/skills/<id>/` (workspace replaces global) and edit there. Other skills under `~/.halo/global/skills/` — including any the user created via the admin UI — are untouched by the seeder.
34
40
 
@@ -75,7 +81,7 @@ You can edit SKILL.md, add scripts, templates, example docs.
75
81
 
76
82
  Click the `+` on the **Global** or **Workspace** group header in the sidebar (the group you click decides the scope) and type a name.
77
83
 
78
- Delete: hover a skill and click its trash icon (after a confirmation). The toggle next to it disables / enables the skill.
84
+ Delete: hover a skill and click its trash icon (after a confirmation). The toggle next to it disables / enables the skill. The **Extensions** group has no `+` and no trash. Its skills go away when the extension is uninstalled, but the toggle works.
79
85
 
80
86
  ## Letting an agent use a skill
81
87
 
@@ -113,10 +119,10 @@ The agent follows the skill's instructions.
113
119
 
114
120
  ## Resource files
115
121
 
116
- Files in the same directory as SKILL.md are listed at the end of `activate_skill`'s return:
122
+ Files in the same directory as SKILL.md are listed at the end of `activate_skill`'s return, under the skill directory's absolute path:
117
123
 
118
124
  ```
119
- Resource files in skill directory:
125
+ Resource files in skill directory (/home/me/.halo/global/skills/code-review):
120
126
  - checklist.md
121
127
  - examples/review-sample.md
122
128
  ```
@@ -161,7 +167,7 @@ Halo seeds these skills on every startup (the ids in `BUILTIN_SKILL_IDS`, `packa
161
167
  | halo | Platform self-knowledge — config directories, MD scopes, which bundled doc answers what (no command, `user-invocable: false`; model auto-activates) |
162
168
  | 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) |
163
169
 
164
- 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.
170
+ 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 > global > extension). `tavily-web-search` remains an optional (non-seeded) skill.
165
171
 
166
172
  ## SKILL.md field reference
167
173
 
@@ -206,7 +212,7 @@ user-invocable: false # optional (standard), never becomes a slas
206
212
 
207
213
  {body}
208
214
 
209
- Resource files in skill directory:
215
+ Resource files in skill directory (<absolute skill dir>):
210
216
  - checklist.md
211
217
  - examples/review.md
212
218
  ...
@@ -124,7 +124,7 @@ Common settings:
124
124
  - `general.session.max_nesting_depth` — max session nesting depth
125
125
  - `general.compact.keep_messages` — recent messages kept intact during compaction
126
126
  - `general.logging.level` — `debug` / `info` / `warn` / `error`
127
- - `general.observability.endpoint` — OTLP collector URL; leave empty to keep observability off (see [design/observability.md](../design/observability.md))
127
+ - `general.observability.endpoint` — OTLP collector URL; leave empty to keep observability off. The server and every `halo cli` / `halo tui` run (cron and evolution runs included) export to it (see [design/observability.md](../design/observability.md))
128
128
  - `<provider-id>.secrets.api_key` — provider credentials (Kimi / DeepSeek bearer token)
129
129
  - `<provider-id>.secrets.access_key_id` / `.secret_access_key` — AWS Bedrock
130
130
  - `<skill-id>.params.<key>` — values an agent can inject into its own `shell_exec` via `{{<skill-id>.params.<key>}}`
@@ -22,6 +22,11 @@ Left sidebar has two collapsible groups (Global / Workspace — the latter only
22
22
 
23
23
  ### Form / Edit dual view
24
24
  - **Form view** (default): data-driven form derived from the YAML fields (name / description / priority / model incl. thinking and capabilities / system_prompt / team / tools / skills). Edits auto-save to `agent.yaml` (debounced 500 ms). If `agent.yaml` fails to load or parse, the form is replaced by an error with a Retry button and nothing is auto-saved (an unparsable file is fixed in Edit view); below the form, a read-only preview of `AGENT.md` is shown when it has content
25
+ - **Model / context fields** in the form:
26
+ - **Endpoint list**: the dropdown offers the model's own `endpoints:` when the registry declares them, else the provider's `endpointPresets`; free text stays allowed.
27
+ - **Auto-switch on model change**: switching to a model with its own list moves the endpoint to that model's default when the current one (or, left empty, the provider default) isn't on the list, with a one-line note naming the new endpoint; a model without a list leaves the endpoint alone. Switching provider sets the new model's default endpoint.
28
+ - **Off-list warning**: a saved / typed endpoint outside the model's own list shows an amber "not listed on this endpoint — requests may fail" hint (not blocked).
29
+ - **Context**: switching model moves `context.maxTokens` / `compressAt` to the new model's recommended values when the old ones were the old model's untouched recommendation (or the window exceeds the new model's max); hand-set values that still fit are kept. The `context.maxTokens` input shows "Model max N" (the registry `maxContextWindow`); an over-max draft turns red ("Exceeds model max N — not saved") and is never auto-saved, and the server refuses it too (400), except a value already on disk for the same model. An empty or non-positive draft is ignored — the saved value stays.
25
30
  - **Edit view**: the **Edit** button opens a mini workspace (file tree + editor) rooted at the agent's folder, for editing `agent.yaml` / `AGENT.md` directly; **Back** returns to the form, which reloads from disk
26
31
  - The chosen view is remembered per agent in `localStorage`
27
32
 
@@ -23,7 +23,7 @@ The primary surface for talking to an agent.
23
23
  - Markdown + code-block highlighting
24
24
  - Tool-call card: expandable, shows tool name / input / output
25
25
  - Sub-agent streams stay out of the chat: their reports land as a green callout, and Debug mode adds agent start/done markers labelled with the agent name (e.g. "Coder", "Researcher")
26
- - A reply that has produced nothing yet shows a spinner with "Thinking..."; once the model streams reasoning text, the spinner gives way to a **live thinking panel**: expanded, muted, with a "Thinking..." header and char count, capped at about 9 lines and tailing its own text (so the chat log grows by at most the cap). It folds to the one-line collapsed "Thinking · N chars" header (click to expand; it stays in the log) as soon as text or a tool call follows, or the message settles. Providers that stream no reasoning text stay on the spinner; Debug mode is unchanged (spinner until text or a tool call arrives, collapsed headers only)
26
+ - A reply that has produced nothing yet shows a spinner with "Thinking..."; once the model streams reasoning text, the spinner gives way to a **live thinking panel**: expanded, muted, with a "Thinking..." header and char count, capped at about 9 lines and tailing its own text (so the chat log grows by at most the cap). It folds to the one-line collapsed "Thinking · N chars" header (click to expand; it stays in the log) as soon as text or a tool call follows, or the message settles. Providers that stream no reasoning text stay on the spinner. Debug mode shows the same live panel; it only adds to the normal view
27
27
  - **Follow the bottom by intent**: while a reply streams the log follows its end, and scrolling up stops the follow. Upward input — mouse wheel, touch drag, PageUp / ArrowUp / Home / Shift+Space, or grabbing the scrollbar — detaches the moment it arrives, so a fast stream can't snap the view back mid-gesture; upward input that a nested scroller (the live thinking panel, an expanded tool body) would take doesn't count. The log re-attaches only when the view is scrolled back down to within 80 px of the end. Applies to the chat panel, the Sessions tab viewer and the live thinking panel alike; a reader scrolling inside the thinking panel stops its tail the same way
28
28
  - The user bubble shows its send time (`HH:mm`, browser-local) at the left of its header row, after the expand chevron (see [User-message actions](#user-message-actions-expand--copy--delete)); hovering it gives the full date. Same bubble component as the Sessions tab, so both surfaces get it
29
29
  - In Debug mode the system-prompt `context` row stays in the list but doesn't open an exchange of its own, so there is no blank strip above the first bubble
@@ -60,11 +60,12 @@ The workspace's root sessions as **Chrome-style vertical tabs** on the right edg
60
60
  - **Rows**: one line per row — just the title, no leading icon cell (the "New session" draft row has none either, so the titles line up). Message count, time-ago, model and the state text (`Working…` / `New messages` / `Idle`) are in the tooltip, the state after a ` · ` at the end of its second line. `N msgs` counts the session's user turns **over its whole lifetime**, so compacting or archiving history never makes it go backwards.
61
61
  - Each row carries a **2px status bar** along its bottom edge, full row width with 6px inset on both sides and rounded ends (absolutely positioned, so a state change never reflows a narrow list — the spinner it replaced did; decorative, `aria-hidden`): **amber, breathing** (`animate-pulse`) while a loaded tab's turn is running; **blue**, solid, when a loaded background tab is idle with unread output (output landed while it wasn't on screen; cleared when shown); **green** otherwise — including sessions no tab has loaded and tabs a reconnect released. Green is the norm, so it is **dimmed** (`/40`) to recede into the background, and rises to `/70` on the selected and hovered row; amber and blue never dim. The list endpoint's `status` is not used. In the light and warm themes amber renders as the theme's darker orange, same as the rest of the UI.
62
62
  - Infinite scroll pages older sessions. A "+" footer starts a new session.
63
- - **Hover peek (collapsed list)**: resting the mouse on the collapsed rail for 300 ms slides the **full list** (header + rows) in over the chat to its left, at the saved width, without reflowing the chat; leaving closes it after a 200 ms grace. Touch never opens it. It stays open while a rename input or the delete confirm started from it is in progress, and selecting a row closes it. Collapsing by mouse click does not peek straight back open under the pointer until it leaves and re-enters; the peek header's expand button pins the list open.
63
+ - **Hover peek (collapsed list)**: resting the mouse on the collapsed rail for 300 ms slides the **full list** (header + rows) in over the chat to its left, at the saved width, without reflowing the chat; leaving closes it after a 200 ms grace. Touch never opens it. It stays open while the row menu, a rename input or the delete confirm started from it is open, and selecting a row closes it. Collapsing by mouse click does not peek straight back open under the pointer until it leaves and re-enters; the peek header's expand button pins the list open.
64
64
  - **New session**: "+", `/session new` and `/clear` open a **draft tab**. A "New session" row sits on top while it is on screen, and an untouched draft is reused rather than duplicated. The session is created by the draft's first message or command. The previous session keeps streaming in its own tab.
65
65
  - **Background tabs**: one WS connection carries every loaded tab, so a session that isn't on screen keeps receiving its turn. After a reconnect only the tab on screen is reattached; the others reload when clicked. Live capture and face (`<<<SHOW>>>`) markers only act for the tab on screen.
66
- - **Delete (trash icon)**: deletes the session after a confirm dialog in the UI language ("Delete this session? Its history cannot be recovered." / its Chinese counterpart). Deletion is the REST delete first, then the WS one, so sub-session files go too. The trash icon, like the **rename** pencil, appears only on hover — the active tab included.
67
- - **Inline rename**: pencil → input in place, Enter/blur commits (`PATCH /sessions/logs/:id`), Esc cancels; empty or unchanged title is a no-op. Other session-list consumers refresh via the `session:changed` WS push
66
+ - **Row menu (Rename / Delete)**: rows carry no hover buttons. Right-click a row (long-press ~500 ms on touch; moving or lifting early cancels) to open a menu at the pointer with **Rename** (inline rename) and **Delete session** (red). Esc or a click outside closes it. The draft row and the collapsed squares have no menu.
67
+ - **Delete**: deletes the session after a confirm dialog in the UI language ("Delete this session? Its history cannot be recovered." / its Chinese counterpart). Deletion is the REST delete first, then the WS one, so sub-session files go too.
68
+ - **Inline rename**: menu → input in place, Enter/blur commits (`PATCH /sessions/logs/:id`), Esc cancels; empty or unchanged title is a no-op. Other session-list consumers refresh via the `session:changed` WS push
68
69
  - **Persistence**: only the session on screen is remembered, per workspace, as `{active}` under `halo_chat_tabs_<projectId>`. A refresh restores and loads only that tab. The pre-tabs keys `halo_session_<projectId>` / `halo_session_id` are read once to seed it and then removed.
69
70
  - **Switch loading**: clicking a row that hasn't loaded yet shows a "Loading session…" state in the message area, cleared only when the server's `state:snapshot` for that exact sessionId arrives (empty sessions included). Past 30s it degrades to "Slow network — still loading…" plus a Retry button (re-subscribes) — slow ≠ failed, nothing aborts on its own. Switching to an already-loaded tab is instant, and its scroll position is restored
70
71
 
@@ -85,7 +86,7 @@ See [requirements/command.md](command.md) for the full command surface.
85
86
  ### Graceful interrupt
86
87
  Sending a new message while the agent is generating **does not** abort — the message goes to the server queue, the agent finishes the current turn at the next safe checkpoint (after a tool call), and then runs the queued message. Queueing multiple messages is supported; they run in order.
87
88
 
88
- **Live placement.** When a main user message or notification lands while the main bubble is still streaming, the live view lays it out the way the server persists it, so it matches what a reload shows. Before logging such a row the server runs `flushCompletedAssistantMessage`: the streaming content up to its first pending tool call settles **above** the row, and the rest (or a fresh empty streaming slot) continues streaming **below** it (`placeAroundStreaming` in `chat-store.ts`).
89
+ **Live placement.** When a main user message or notification (an error row included) lands while the main bubble is still streaming, the live view lays it out the way the server persists it, so it matches what a reload shows. Before logging such a row the server runs `flushCompletedAssistantMessage`: the streaming content up to its first pending tool call settles **above** the row, and the rest (or a fresh empty streaming slot) continues streaming **below** it (`placeAroundStreaming` in `chat-store.ts`).
89
90
 
90
91
  ### Stop
91
92
  The Stop button hard-aborts — the server receives `chat:stop`, AbortController fires, queue clears, buffers flush.
@@ -100,6 +101,9 @@ When `contextEnabled` is on (default), user messages are auto-prepended with:
100
101
  - Clipboard paste
101
102
  - File-picker button
102
103
  - Screenshot button (below)
104
+ - Canvas image **Annotate → Send to chat** (`<name>-annotated.png`; see `requirements/editor.md` → Image annotation)
105
+
106
+ On an agent whose model has no image input, an annotated image is not attached — the attachment-notice row says the model doesn't take images (`chat.attachNoVision`).
103
107
 
104
108
  Images ride along as base64; multimodal supported. Before sending, every attachment is downscaled (long edge ≤ 1568) and re-encoded as JPEG; an SVG is rasterized the same way (long edge 1024, on white), since vision input only takes jpeg / png / gif / webp. An image the browser can't decode (e.g. HEIC / TIFF in Chrome) and that isn't one of those four is refused when attached, with an inline notice naming the file — it is never sent only to be dropped server-side. Pasted images are also persisted to `<workspace>/.halo/assets/web/inbound/web/<date>/` so a `[图片已保存: /abs/path]` marker survives page reload and renders as a click-to-preview chip (shared with the WeChat channel's inbound media flow).
105
109
 
@@ -25,6 +25,10 @@ The rest are noun-verb **object commands**: `/<obj> <verb> [args]`. Some verbs a
25
25
 
26
26
  Session lifecycle actions (`subscribe` / `unsubscribe`, `session:delete`) are handled inline by the WS handler, not as slash commands. In the admin, a bare `/session new` (and `/clear`) is handled client-side: it opens a new draft chat tab and sends nothing; `/session new <args>` still goes to the server. The WS `session:clear` frame was removed in 1.5.3-alpha.
27
27
 
28
+ ### `/session new`
29
+
30
+ Built-in verb, all users. Creates a new root session with the workspace's entry agent and switches the caller to it. The new session **keeps the current session's folder** (its `working_dir`, e.g. from `halo tui --dir sub`): from a sub-agent, its root's folder. The folder is re-checked; if it no longer exists, the new session starts at the workspace root.
31
+
28
32
  ### `/session info`
29
33
 
30
34
  Built-in verb (no access gate beyond ownership). Prints the **session tree** for the caller's current root session — the root plus every descendant sub-agent, so the user can see the whole delegation fan-out at a glance:
@@ -26,10 +26,15 @@ Monaco-based multi-tab code editor + binary file previewer — surfaced in the U
26
26
  - Server returns the new `modifiedAt`; the tab updates its mtime
27
27
 
28
28
  ### Auto-refresh (mtime-based)
29
- When agents write files through tools, the canvas detects it — push-based, no polling:
30
- 1. The server's WS `file:changed` (`change`) event re-reads the matching open tab; the active tab is also re-checked with `GET /api/files/stat` on tab switch, window focus and WS reconnect
31
- 2. If `diskMtime > tab.mtime`, pull fresh content
32
- 3. Skip if the user has unsaved edits (don't overwrite)
29
+ When agents or scripts write files, the canvas picks it up — push-based, no polling:
30
+ 1. A WS `file:changed` `change` **or `add`** event (`add` = a file renamed over the open one, e.g. `sed -i` or a tmp-file replace) refreshes every open buffer of that path, in all panes. Tab switch, window focus and WS reconnect re-check only the focused pane's active tab (`GET /api/files/stat`)
31
+ 2. Only a disk mtime strictly newer than the buffer's counts
32
+ 3. Text tab: pull fresh content — skipped if the user has unsaved edits (don't overwrite)
33
+ 4. Built-in preview: remounts with a versioned URL (fresh fetch / parse, no stale browser copy), so its view state (zoom, page, annotation marks) resets. Extension preview: not remounted — its host reloads the file inside the running extension (see *Editable extensions*). Bundle directories are skipped
34
+ 5. HTML preview: reloads only when the `.html` file itself changes and its text tab has no unsaved edits; changes to linked CSS / JS / images don't trigger it
35
+ 6. The pinned face (`self.html`) is excluded — a reload would reset it
36
+
37
+ With no session selected no `file:changed` arrives (the WS subscribes on session select), so only the tab-switch / focus / reconnect checks apply.
33
38
 
34
39
  ### Tab persistence
35
40
  Open tabs and the active tab live in localStorage; survive refresh.
@@ -47,7 +52,7 @@ Non-text files go through `FilePreview`, which looks up a **plugin** in the prev
47
52
  - **Parquet** — server-paginated table (schema + 100-row pages), same pager as CSV/SQLite
48
53
  - **SQLite / DB (.db / .sqlite / .sqlite3)** — server-paginated table with a table-selector sidebar (name + row count per table); opened read-only, one page (100 rows) per request
49
54
  - **PPTX / PPT** — `pptx-preview` list mode. Fidelity is approximate — complex animations / SmartArt / embedded fonts may not render perfectly; users can download for the exact source. Flagged `heavy: true` so only the *active* pptx mounts (canvas-based rendering needs the main thread). **Speaker-notes sidebar**: notes are extracted from the pptx zip in presentation (play) order and listed on the left; clicking a note scrolls to the corresponding slide (a domIndex mapping bridges play order to pptx-preview's part-filename render order, so reordered decks still land on the right slide). Collapsible with the state remembered in localStorage (`halo.pptxNotesHidden`); the sidebar doesn't appear at all when no slide has notes. Decks with dangling `[Content_Types].xml` overrides (e.g. some WPS exports) are repaired before rendering; if zero slides render anyway, a real error UI is shown instead of a silent black wrapper
50
- - **Images / video / audio** — `<img>` (via `SafeImage`: pulsing placeholder while loading, "Image unavailable" box on failure, never the broken-image icon; zoom / pan unchanged) / native `<video>` / `<audio>`, supports HTTP Range for seek-without-full-download. Markdown-preview images use the same placeholder.
55
+ - **Images / video / audio** — `<img>` (via `SafeImage`: pulsing placeholder while loading, "Image unavailable" box on failure, never the broken-image icon; wheel / toolbar zoom, left- or middle-drag pan, reset; **Annotate** — below) / native `<video>` / `<audio>`, supports HTTP Range for seek-without-full-download. Markdown-preview images use the same placeholder.
51
56
 
52
57
  All previews share a **standard header** (`PreviewShell`) with filename, Download, Open-as-Text, and an `extraToolbar` slot for plugin-specific buttons (e.g. DOCX Print, XLSX sheet tabs).
53
58
 
@@ -55,6 +60,14 @@ Fetched via `GET /api/files/download?inline=1` (supports Range, streams on the s
55
60
 
56
61
  Adding new file types is a plugin concern — see `dev/previews.md` for the extension guide.
57
62
 
63
+ ### Image annotation
64
+ An image preview's toolbar has **Annotate**: a drawing board over the picture to mark it up and send it to the agent. Offered only once the image has loaded, not for SVG (may have no intrinsic size), and only in the main Canvas — not the Skills / Agents editors, whose chat is hidden.
65
+ - Tools: pen / rectangle / arrow; colours red (default) / yellow / green / blue; undo (also Ctrl/Cmd+Z), clear. Lines are drawn 4 px wide at the current zoom. A flat rectangle or zero-length arrow is dropped
66
+ - Zoom and pan stay live: wheel / toolbar zoom, middle-drag pans (left / touch drag draws). A second finger or a palm while drawing is ignored — it neither pans nor draws
67
+ - **Send to chat** renders the image at full resolution (long edge capped at 4096, marks scaled with it) as `<name>-annotated.png`, attaches it to the chat input like any upload, closes the board and puts the caret in the chat input — un-maximizing Canvas and switching the bottom panel to Chat if needed. On an agent whose model has no image input nothing is attached; the attachment-notice row says so. If rendering fails, an inline error shows and the marks stay
68
+ - **Esc**, ✕ or the Annotate button again exits; while maximized the first Esc only exits annotate. Esc and Ctrl/Cmd+Z don't act while typing in an input / Monaco
69
+ - Marks are never saved: exiting, switching the pane to another tab, or the file changing on disk discards them
70
+
58
71
  ### Preview extensions (installable viewers)
59
72
  File types without a built-in plugin can be handled by an **installed extension**: a static HTML bundle under `~/.halo/global/extensions/<id>/` (server-wide, not per workspace) that Canvas loads in a sandboxed iframe (scripts + same-origin, like the HTML preview — an extension is trusted like a skill the user chose to install). Design: `design/canvas-extensions.md`.
60
73
  - **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
@@ -65,6 +78,7 @@ File types without a built-in plugin can be handled by an **installed extension*
65
78
  - **Export from an editable extension** (e.g. draw.io *File ▸ Export as*, Excalidraw main menu *Export PNG / SVG*): the extension iframe can't download files, so the exported image is saved **into the workspace, next to the open file** (`flow.drawio` → `flow.png`). If that file already exists the user is asked whether to overwrite it. On success a banner "Exported to <path>" appears above the preview with a **Download** link. Exporting never marks the tab modified and never changes the open file. Only on Halo builds newer than 1.5.9; on older hosts the extension shows no working export (drawio ≥ 1.1.0 / excalidraw ≥ 1.1.0 hide or fall back on their own)
66
79
  - **Bundle extensions** (manifest `bundle: true`, e.g. the `htrans` meeting recorder): a *directory* named with the extension's suffix (`Standup.htrans/`) is one document. In the Explorer it shows a package icon; clicking the row (or Enter) opens it as a tab, and only its chevron expands it so the files inside stay browsable. Right-click → Open to the Side works on it. **New File** with a bundle suffix (`Standup.htrans`) creates the directory and opens it — from the file tree's inline New File and from the Explorer header's New File button alike. The extension reads and writes only inside that directory. While it reports busy (e.g. recording) the tab shows the dirty dot, asks before closing, and stays mounted when you switch tabs. Ctrl/Cmd+S / Save do nothing on it. A bundle tab survives a reload while the extension is installed and supported here; otherwise the tab is dropped and the directory is an ordinary folder again
67
80
  - **File access beyond the open file** (manifest capability `fs-read`; Settings → Extensions tags it "Reads files on this machine"): a viewer such as an emulator or model viewer may read other files in the workspace or anywhere on the server machine (never write), and ask the user to choose one. The choice is made in a **file picker** the viewer opens on demand: Workspace and Home shortcuts, an editable path box, a breadcrumb, folders first, only files matching the viewer's suffix filter; arrow keys / Enter / Backspace to navigate, Esc or Cancel to close it. A file inside the workspace is handed back as a workspace-relative path, any other as an absolute one. Halo's own secrets (`~/.halo/secrets`, `~/.ssh`, git credentials, the global databases — the same paths the agent sandbox hides) never show up in the picker and can't be read this way, symlinks included
81
+ - **Running notebook cells** (ipynb 2.0.0+ on Halo 1.6.1+): the ipynb viewer edits and saves cells (an editable extension, as above) and runs them on a real kernel on the Halo machine through Halo's Jupyter proxy — any kernelspec the configured Jupyter finds (Python, R, …), started with the notebook's folder as the working directory. Halo starts a local Jupyter server on first use; Jupyter itself is not bundled (`pip install jupyter_server ipykernel`; the viewer shows that hint when it is missing). Same trust as the built-in terminal: the kernel runs as the Halo service user, unsandboxed, and only a logged-in admin can reach it (no web-channel / IM / A2A tokens; not in AgentCore mode). Settings → General: `jupyter.enabled` (allow notebook execution, default on) and `jupyter.path` (full path to `jupyter`, empty = PATH), both applied without a restart. Closing the notebook tab shuts its kernel down after 60 s (a reload within that keeps it); a kernel idle for 1 h is shut down even while the tab is open; stopping Halo stops every kernel. Older hosts don't advertise it: the viewer still edits and saves, without run controls
68
82
  - **Platforms** (manifest `platforms`: `web` / `desktop-mac` / `desktop-win` / `desktop-linux`; omitted = everywhere): on an excluded platform (the Halo desktop app is detected by its user agent) the extension acts as if it were not installed. The fallback page says `"<name>" only runs on: <platforms>`, and Settings → Extensions shows a platforms badge plus a gray "not supported here" tag. Manifest capability `media` lets the extension use the microphone and screen capture
69
83
  - **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
70
84
  - **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
@@ -78,7 +92,7 @@ Markdown and HTML open as text in Monaco *and* have a rendered view — Canvas d
78
92
  - Toggle to Edit → Monaco source, Cmd+S saves as usual
79
93
 
80
94
  ### Preview caching (MRU)
81
- 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. Cached previews keep a fixed (path-sorted) order in the DOM rather than moving with recency, so switching tabs never reloads an extension iframe — a running emulator keeps its state. Closing a preview tab removes it from the cache immediately (aborting any in-flight fetch).
95
+ 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 `.glb`) remounts the viewer, so it shows the newly active file. An extension tab with unsaved edits stays pinned beyond the 5. Cached previews keep a fixed (path-sorted) order in the DOM rather than moving with recency, so switching tabs never reloads an extension iframe — a running emulator keeps its state. Closing a preview tab removes it from the cache immediately (aborting any in-flight fetch).
82
96
 
83
97
  ### File metadata in header
84
98
  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.
@@ -25,7 +25,7 @@ Session history viewer: hierarchy tree, message playback, debug mode, system pro
25
25
  Top Debug toggle (Bug icon), persisted in `localStorage` as `halo_session_debug`. When on:
26
26
  - **Normal mode**: user message + assistant reply (with inline tool-call cards, click to expand IN / OUT); sub-agent notifications hidden
27
27
  - **Debug mode**: adds, on top of the normal view:
28
- - **Thinking** blocks (purple, expandable, with char count) inside assistant messages
28
+ - **Thinking** blocks (purple, expandable, with char count) inside assistant messages. While a reply streams, its thinking shows in the same live panel as the normal view
29
29
  - **Usage** line after each turn — timestamp, token counts (in / out / ctx, cache read / write / hit ratio), latency (ttft / e2e), thinking effort, model ID
30
30
  - Sub-agent start / done markers labelled with the agent name
31
31