@turmind/halo 2.0.1 → 2.0.3

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 (41) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/241-75bac3bb8e7aca1b.js +1 -0
  3. package/admin-out/_next/static/chunks/989-5c90f88e8111b360.js +1 -0
  4. package/admin-out/_next/static/chunks/app/_not-found/{page-4271518f2746cf73.js → page-484fd244e4bc7b70.js} +1 -1
  5. package/admin-out/_next/static/chunks/app/layout-3e437a977abd0081.js +1 -0
  6. package/admin-out/_next/static/chunks/app/page-faee6fbd17dc7b80.js +1 -0
  7. package/admin-out/_next/static/chunks/{main-app-340a5b58ed229c4d.js → main-app-42f48cb3fd4811f4.js} +1 -1
  8. package/admin-out/_next/static/chunks/{webpack-74f577c1b094f942.js → webpack-b7907e0d16faa4ca.js} +1 -1
  9. package/admin-out/_next/static/css/{e890aa0d13e94290.css → 2e6b00182ac44ae1.css} +1 -1
  10. package/admin-out/index.html +1 -1
  11. package/admin-out/index.txt +5 -5
  12. package/bundled-docs/design/a2a.md +1 -1
  13. package/bundled-docs/design/agentcore.md +385 -34
  14. package/bundled-docs/design/canvas-extensions.md +76 -29
  15. package/bundled-docs/design/cron.md +1 -1
  16. package/bundled-docs/design/observability.md +4 -2
  17. package/bundled-docs/design/relay.md +2 -2
  18. package/bundled-docs/design/session.md +1 -1
  19. package/bundled-docs/design/storage.md +25 -3
  20. package/bundled-docs/design/stt-local.md +2 -2
  21. package/bundled-docs/design/ws.md +2 -1
  22. package/bundled-docs/dev/api.md +93 -13
  23. package/bundled-docs/dev/deploy.md +6 -0
  24. package/bundled-docs/dev/env.md +8 -4
  25. package/bundled-docs/dev/tools.md +1 -1
  26. package/bundled-docs/guide/cli.md +25 -11
  27. package/bundled-docs/guide/skills.md +2 -2
  28. package/bundled-docs/guide/workspace.md +2 -2
  29. package/bundled-docs/requirements/editor.md +4 -4
  30. package/bundled-docs/requirements/settings.md +16 -4
  31. package/dist/index.js +4011 -2135
  32. package/package.json +2 -1
  33. package/templates/skills/extension/SKILL.md +21 -16
  34. package/templates/skills/extension/config.yaml +6 -2
  35. package/templates/skills/extension/templates/ext.sh +137 -57
  36. package/admin-out/_next/static/chunks/87-6a2716c98a819c4e.js +0 -1
  37. package/admin-out/_next/static/chunks/989-cb287787a4aca288.js +0 -1
  38. package/admin-out/_next/static/chunks/app/layout-ea161287d934ba96.js +0 -1
  39. package/admin-out/_next/static/chunks/app/page-c9fb252d0cf34c20.js +0 -1
  40. /package/admin-out/_next/static/{52ayDC9phpvb-95S7qHHq → P7yMYf16Q3-XqGtzCJNnw}/_buildManifest.js +0 -0
  41. /package/admin-out/_next/static/{52ayDC9phpvb-95S7qHHq → P7yMYf16Q3-XqGtzCJNnw}/_ssgManifest.js +0 -0
@@ -1,6 +1,6 @@
1
1
  # Canvas preview extensions — installable file viewers in sandboxed iframes
2
2
 
3
- > API: [dev/api.md → Canvas Preview Extensions](../dev/api.md#canvas-preview-extensions). WS frame `extension:changed`: [ws.md](ws.md#other-server--client-messages). User-visible behavior: [requirements/editor.md](../requirements/editor.md). Code: `packages/core/src/protocol/extension-{types,frames}.ts`, `packages/server/src/extensions/{registry,watcher,install}.ts` + `routes/extensions.ts` + `middleware/auth.ts` (scoped token), `PUT /files/raw` in `routes/files.ts`, `routes/transcribe-ws.ts` (transcription proxy) + `settings-schema.ts` `extensionSections()`, `packages/admin/src/features/editor/previews/{registry,extension-host,extension-host-logic,extension-token}.ts(x)`, `packages/admin/src/features/workspace/extension-activity-{panel,logic}.ts(x)` (activity-bar pages + the shared file-less `ExtensionPageFrame`), `packages/admin/src/features/chat/extension-chatbar-{control,logic}.ts(x)` (chat-bar panels), `previews/extension-icon.tsx` (mask icon), `packages/server/templates/skills/extension/`, `packages/server/src/agents/skill-sources.ts` (extension-shipped skills).
3
+ > API: [dev/api.md → Canvas Preview Extensions](../dev/api.md#canvas-preview-extensions). WS frames `extension:changed` / `updates:changed`: [ws.md](ws.md#other-server--client-messages). User-visible behavior: [requirements/editor.md](../requirements/editor.md). Code: `packages/core/src/protocol/extension-{types,frames}.ts`, `packages/server/src/extensions/{registry,watcher,install,state}.ts` + `routes/extensions.ts` + `middleware/auth.ts` (scoped token), `PUT /files/raw` in `routes/files.ts`, `routes/transcribe-ws.ts` (transcription proxy) + `settings-schema.ts` `extensionSections()`, `packages/admin/src/features/editor/previews/{registry,extension-host,extension-host-logic,extension-token}.ts(x)`, `packages/admin/src/features/workspace/extension-activity-{panel,logic}.ts(x)` (activity-bar pages + the shared file-less `ExtensionPageFrame`), `packages/admin/src/features/chat/extension-chatbar-{control,logic}.ts(x)` (chat-bar panels), `previews/extension-icon.tsx` (mask icon), `packages/server/src/updates/` + `routes/updates.ts` (update checks, see [Updates](#updates-updatejson)), `packages/admin/src/features/settings/{extensions-view,core-update}.tsx`, `packages/server/templates/skills/extension/`, `packages/server/src/agents/skill-sources.ts` (extension-shipped skills).
4
4
 
5
5
  ## Problem
6
6
 
@@ -27,7 +27,7 @@ The admin editor's preview set was compile-time: eight plugins registered in `pr
27
27
  |---|---|---|
28
28
  | `id` | ✓ | `ID_RE`; **must equal the directory name** (`id mismatch` otherwise) |
29
29
  | `name` | ✓ | ≤ 64 chars; shown in the open-with menu and Settings → Extensions |
30
- | `version` | ✓ | semver `x.y.z[-pre]`; compared for **equality only**; part of the asset URL |
30
+ | `version` | ✓ | semver `x.y.z[-pre]`; part of the asset URL. Install compares nothing (same id = replace); the [update check](#updates-updatejson) compares it with `semver.gt` against update.json |
31
31
  | `extensions` | ✓ | array, each `^\.[a-z0-9]+$` (dot, lowercase); matched against the lowercased file ext. Non-empty (`extensions must be a non-empty array`) unless the manifest declares `activity` or `chatbar`, where `[]` = opens no files; the key stays required either way (`ext.sh`'s pre-check demands it) — with `activity` / `chatbar`, a non-array is `extensions must be an array (empty is fine with activity or chatbar)` |
32
32
  | `entry` | ✓ | relative HTML path, no leading `/`, no `..`; file must exist |
33
33
  | `priority` | – | `default` (default; opens the file directly) \| `option` (open-with menu only) |
@@ -38,18 +38,19 @@ The admin editor's preview set was compile-time: eight plugins registered in `pr
38
38
  | `activity` | – | `{ icon?, title?, title_zh?, description?, description_zh?, order? }` = one admin activity-bar button (see [Activity-bar entries](#activity-bar-entries)). Checked after `description`, before `extensions`: non-object → `activity must be an object`; `icon` relative `.svg` / `.png` (no leading `/`, no `..`, `/\.(svg\|png)$/i`) else `activity.icon must be a relative .svg or .png path inside the extension directory`, and must be a file (`activity icon not found: <icon>`); `title` / `title_zh` non-blank ≤ 64 (`activity.<k> must be a non-empty string (≤ 64 chars)`); `description` / `description_zh` ≤ 200 (`activity.<k> must be a string (≤ 200 chars)`); `order` integer 0–1000 (`activity.order must be an integer 0–1000`), default 100. Unknown keys inside are ignored; `ExtensionInfo.activity` carries only the fields present plus `order` |
39
39
  | `chatbar` | – | `{ icon?, title?, title_zh?, height?, order? }` = one button on the chat input toolbar (see [Chat-bar entries](#chat-bar-entries)). Checked after `activity`, before `extensions`: non-object → `chatbar must be an object`; `icon` same rule as `activity.icon` (`chatbar.icon must be a relative .svg or .png path inside the extension directory`, `chatbar icon not found: <icon>`); `title` / `title_zh` non-blank ≤ 64 (`chatbar.<k> must be a non-empty string (≤ 64 chars)`); `height` integer 80–480 (`chatbar.height must be an integer 80–480`), default 160; `order` integer 0–1000 (`chatbar.order must be an integer 0–1000`), default 100. Unknown keys inside are ignored; `ExtensionInfo.chatbar` carries only the fields present plus `height` and `order` |
40
40
  | `description` / `homepage` / `license` | – | ≤ 200 chars / http(s) URL / free string, display only |
41
+ | `source` | – | https URL of this extension's own `update.json` (`/^https:\/\/\S+$/` + `URL.canParse`, else `source must be an https URL (of the update.json)`); omitted = `<hub_base>/<id>/update.json`. See [Updates](#updates-updatejson) |
41
42
 
42
- No `keepAlive` / `readonly` field: both are derived from `capabilities` (see MRU). No `minHaloVersion`: compatibility is the protocol version carried in `init`.
43
+ No `keepAlive` / `readonly` field: both are derived from `capabilities` (see MRU). No `minHaloVersion`: compatibility is the protocol version carried in `init`. A minimum Halo version for a *release* lives in its `update.json` (`minHalo`), checked before an update is offered or `ext.sh install <id>` downloads it — never at scan time.
43
44
 
44
45
  ### Atomic install (`install.ts`, mirrored by `ext.sh`)
45
46
 
46
- Every writer follows six steps so the root watcher sees one rename per change: unpack into `.tmp-unpack-<rand>` → strip one wrapper directory (`zip -r glb.zip glb/` shape) → pre-check with the scanner's own `readExtensionDir` → move existing `<id>/` aside to `.old-<id>-<rand>` → rename staging → `<id>/` → remove `.old` (on step-5 failure it is moved back). The unpack refuses entries with absolute paths, `..`, NUL, or symlink mode bits. Same id = upgrade *or downgrade* — there is no version comparison. `expectId` (skill path) must match the manifest id; the admin upload takes whatever the manifest says. A failed pre-check never touches a working install.
47
+ Every writer follows six steps so the root watcher sees one rename per change: unpack into `.tmp-unpack-<rand>` → strip one wrapper directory (`zip -r glb.zip glb/` shape) → pre-check with the scanner's own `readExtensionDir` → move existing `<id>/` aside to `.old-<id>-<rand>` → rename staging → `<id>/` → remove `.old` (on step-5 failure it is moved back; the removal itself retries 3 times and, if it still fails, only logs `[Extensions] could not remove the previous copy …` — the install already succeeded, and the dot-named leftover is skipped by the scanner). The unpack refuses entries with absolute paths, `..`, NUL, or symlink mode bits. Same id = upgrade *or downgrade* — there is no version comparison. `expectId` (skill path, update route) must match the manifest id; `expectVersion` (update route only: the update.json `version`) must match the manifest version; the admin upload takes whatever the manifest says. A failed pre-check never touches a working install. A **fresh** install (no `<id>/` before) drops any on / off entry left in `.state.json` (a manual `rm -rf` leaves one), so it starts on; replacing an existing `<id>/` keeps the switch — see [On / off](#on--off).
47
48
 
48
49
  ## Server
49
50
 
50
- **Registry** (`registry.ts`). `scanExtensions()` runs at boot and when the watcher fires — never per request; routes read the cached `getSnapshot()`. Directories starting with `.` are skipped; a non-dot directory whose name isn't a valid id is listed as an error. `installedAt` = directory mtime, which the final rename refreshes (drives newest-wins). `parseManifest` is pure and takes an `entryExists` callback so it is unit-testable without a disk layout.
51
+ **Registry** (`registry.ts`). `scanExtensions()` runs at boot and when the watcher fires — never per request; routes read the cached `getSnapshot()` = `{ extensions, disabled, errors }`: valid extensions go to `disabled` when switched off (see [On / off](#on--off)); `getExtension(id)` looks in `extensions` only, `getInstalledExtension(id)` in both. Directories starting with `.` (staging, `.old-*`, `.state.json`) are skipped; a non-dot directory whose name isn't a valid id is listed as an error. `installedAt` = directory mtime, which the final rename refreshes (drives newest-wins). `parseManifest` is pure and takes an `entryExists` callback so it is unit-testable without a disk layout.
51
52
 
52
- **Watcher** (`watcher.ts`). Non-recursive `fs.watch` on the root (same shape and same trade-off as `ws/git-dir-watcher.ts`): every install path ends in a rename *into* or an rm *of* a direct child, so one inode suffices and recursive-watch flakiness is avoided. Event burst → 300 ms debounce → rescan → compare `snapshotKey` (`id@version#installedAt` per extension + error list) → broadcast `extension:changed` (the **full** `ExtensionsSnapshot`, global `broadcast()`, not a diff) only if the key changed, so `.tmp-*` churn is silent. `error` → close, retry after 1 s. `rescanAndBroadcast()` is the single notifier: routes call it after their own writes so the response and the next `GET` are already fresh, but never broadcast themselves. Not started in AgentCore mode (no admin editor there).
53
+ **Watcher** (`watcher.ts`). Non-recursive `fs.watch` on the root (same shape and same trade-off as `ws/git-dir-watcher.ts`): every install path ends in a rename *into* or an rm *of* a direct child, so one inode suffices and recursive-watch flakiness is avoided. Event burst → 300 ms debounce → rescan → compare `snapshotKey` (`id@version#installedAt` per switched-on extension, the same prefixed `-` per switched-off one, `!id:error` per error row) → broadcast `extension:changed` (the **full** `ExtensionsSnapshot`, global `broadcast()`, not a diff) only if the key changed, so `.tmp-*` churn is silent; after a broadcast it re-judges the cached update verdicts (`rejudgeExtensionUpdates`, no network). `error` → close, retry after 1 s. `rescanAndBroadcast()` is the single notifier: routes call it after their own writes so the response and the next `GET` are already fresh, but never broadcast themselves. Not started in AgentCore mode (no admin editor there).
53
54
 
54
55
  Why not a "rescan" endpoint for the skill: an agent's shell has no admin cookie, so it would need a second auth scheme, and the admin-upload and skill paths would diverge. With `fs.watch` both converge on the same code with zero auth surface.
55
56
 
@@ -57,9 +58,11 @@ Why not a "rescan" endpoint for the skill: an agent's shell has no admin cookie,
57
58
 
58
59
  | Route | Auth | Notes |
59
60
  |---|---|---|
60
- | `GET /extensions` | cookie | `ExtensionsSnapshot` (valid + errors) |
61
+ | `GET /extensions` | cookie | `ExtensionsSnapshot` (on + off + errors) |
61
62
  | `POST /extensions/install` | cookie | multipart `file` = zip, `MAX_ZIP_BYTES` 100 MB (413 by `Content-Length`, then by `file.size`); 400 `{error}` on `ExtensionInstallError`, 500 otherwise |
62
- | `DELETE /extensions/:id` | cookie | `rm -rf`; 404 if not installed |
63
+ | `PATCH /extensions/:id` | cookie | `{ enabled: boolean }` = target state (a repeat is a no-op: no write, no push) → `{ ok, id, enabled }`; 400 bad id / body, 404 not installed or an error row (those can only be removed) |
64
+ | `POST /extensions/:id/update` | cookie | One-click update, no body — see [Updates](#updates-updatejson); 200 = the new `ExtensionInfo` |
65
+ | `DELETE /extensions/:id` | cookie | `rm -rf` + drop its `.state.json` entry; 404 if not installed |
63
66
  | `GET /extensions/token` | cookie | `{ token, expiresAt }` — asset token, 24 h |
64
67
  | `GET /extensions/:id/:version/:token/*` | **token in path** | static asset, below |
65
68
 
@@ -178,10 +181,10 @@ An extension that can render its document as an image (draw.io *File ▸ Export
178
181
  ### MRU, keep-alive, upgrade, uninstall
179
182
 
180
183
  - `editor-panel` keeps the last 5 preview tabs mounted (`PREVIEW_CACHE_SIZE`); "heavy" previews mount active-only. `isHeavyPreview` is true for an extension **without** `save` and not `bundle`: nothing to lose on unmount, and each viewer may hold a WebGL context (Chrome caps ~16 per page). An extension **with** `save` stays in the MRU because its dirty state lives only in the iframe; a dirty preview is never evicted past the cap. The mounted previews render in **path-sorted** order, not MRU order: promoting a tab reorders the MRU, React would then move the keyed nodes with `insertBefore`, and moving an iframe in the DOM reloads it — a running emulator or editor would start over on every tab switch. With a stable order, insertions and removals never move the siblings. The active heavy preview's wrapper is keyed by path: `FilePreview`'s `Dispatch` is keyed only by file extension and a host posts the file bytes once per mount, so switching between two heavy tabs of the same type (two `.ipynb`) used to keep showing the first file.
181
- - **Upgrade while mounted** (`extension:changed` gives the tab a new `info.version`): not dirty → remount with the new URL; dirty → non-blocking banner "updated to vX" with a Reload link (`reload` asks to discard first).
182
- - **Uninstall while mounted**: not dirty → `resolve()` drops the candidate and the tab falls to the next one (built-in plugin if the ext has one, else the fallback page). Dirty → `Dispatch` keeps rendering the last-shown extension (`uninstalled` banner) so the edits can still be saved; the loaded iframe already holds its assets, though a fresh load would 404. Once saved, the tab falls through the same way.
183
- - **Fallback page** (`UnsupportedPreview`): "This file type has no built-in preview. Extensions for more file types are available at halo-hub ↗" + Open as Text / Download. The link is a constant — no lookup, no per-ext URL, no online recommendation (would need a network call or a bundled index that goes stale).
184
- - **Settings → Extensions** (`features/settings/extensions-view.tsx`): upload zip, list (errors in red), remove with `confirmAction`. It never re-fetches after a mutation — `extension:changed` updates it.
184
+ - **Upgrade while mounted** (`extension:changed` gives the tab a new `info.version` — from an upload, `ext.sh`, or the one-click Update): not dirty → remount with the new URL; dirty → non-blocking banner "updated to vX" with a Reload link (`reload` asks to discard first).
185
+ - **Uninstall or switch-off while mounted**: not dirty → `resolve()` drops the candidate and the tab falls to the next one (built-in plugin if the ext has one, else the fallback page). Dirty → `Dispatch` keeps rendering the last-shown extension so the edits can still be saved, with a banner picked by whether the id is now in `snapshot.disabled` (`gone: 'disabled'` → "has been turned off", else `'uninstalled'`); the loaded iframe already holds its assets — after an uninstall a fresh load would 404, a switched-off one still serves (assets go through `getInstalledExtension`). Once saved, the tab falls through the same way.
186
+ - **Fallback page** (`UnsupportedPreview`): "This file type has no built-in preview. Extensions for more file types are available at halo-hub ↗" + Open as Text / Download. A switched-off extension that claims the suffix adds one line, `"<name>" is turned off — turn it on in Settings → Extensions.` (`disabledFor()`, the only reader of `snapshot.disabled` outside Settings). The link is a constant — no lookup, no per-ext URL, no online recommendation (would need a network call or a bundled index that goes stale).
187
+ - **Settings → Extensions** (`features/settings/extensions-view.tsx`): upload zip, **Check for updates** + "Last checked hh:mm" in the header, and one id-sorted list of on and off extensions (off rows dimmed with an "Off" tag; errors in red). Each valid row has, in order, an **Update** button when its verdict is `available`, a `role="switch"` on / off toggle (PATCH) and Remove (`confirmAction`); error rows get Remove only. Update, switch, upload and remove share one busy flag. The verdict line under a row: `Latest X` (blue, `available`), `X needs Halo Y — upgrade Halo first`, `No update info at the source`, `No update source configured`, or the error for `invalid` / `refused` / `unreachable`; a verdict shows only when its `id@installed` matches the row's `id@version`, so a stale one never sits on a just-updated row. It never re-fetches after a mutation — `extension:changed` and `updates:changed` update it.
185
188
 
186
189
  ### Immersive maximize
187
190
 
@@ -242,13 +245,13 @@ Manifest `chatbar` gives an extension one button on the chat input toolbar; clic
242
245
 
243
246
  ### Extension settings
244
247
 
245
- An installed extension with manifest `settings` gets one Settings section (`settings-schema.ts` `extensionSections()`, built from the registry's cached snapshot): `source: 'extension'`, namespace `ext-<id>` (prefixed so an extension id can't collide with a skill / provider namespace), title = manifest `name`, every field `globalOnly`, secrets `secret: true` (masked). Values live in the global `~/.halo/secrets/settings.yaml` at `ext-<id>.params.<key>` / `ext-<id>.secrets.<key>` and go through the ordinary `/settings` routes (workspace scope refused by the global-only backstop). Admin: Settings nav group **Extension settings** under Extensions. Values never reach the manifest, the hub or the `init` frame — only server code reads them (`getServerParam` / `getServerSecret` in `config.ts`, read per use, so no restart).
248
+ An installed extension with manifest `settings` gets one Settings section (`settings-schema.ts` `extensionSections()`, built from the registry's cached snapshot): `source: 'extension'`, namespace `ext-<id>` (prefixed so an extension id can't collide with a skill / provider namespace), title = manifest `name`, every field `globalOnly`, secrets `secret: true` (masked). Values live in the global `~/.halo/secrets/settings.yaml` at `ext-<id>.params.<key>` / `ext-<id>.secrets.<key>` and go through the ordinary `/settings` routes (workspace scope refused by the global-only backstop). Admin: Settings nav group **Extension settings** under Extensions. A switched-off extension gets no section, like an uninstalled one, but its values stay in `settings.yaml`: `extensionOffNamespaces()` (its `ext-<id>` plus the ids of the skills it ships) keeps them out of the Orphans list, so they can't be pruned there and come back on re-enable. Values never reach the manifest, the hub or the `init` frame — only server code reads them (`getServerParam` / `getServerSecret` in `config.ts`, read per use, so no restart).
246
249
 
247
250
  ### Streaming transcription proxy
248
251
 
249
252
  WS `/api/transcribe/stream?ext=<id>&lang=<auto|xx-XX|xx-XX,yy-YY,…>` (`routes/transcribe-ws.ts`) relays an extension's live audio to **Amazon Transcribe streaming** so AWS credentials never reach the browser. Every WS endpoint (`/ws` admin socket, this proxy, `/api/jupyter/channels`) is a `noServer` ws server behind one http `upgrade` listener, `ws/upgrade-router.ts` (`createUpgradeRouter`: exact-pathname lookup in a Map, plain `split('?')` instead of `new URL()` since it runs before auth on raw input; unknown path → `400`; AgentCore mode registers no routes, so every upgrade gets that 400) — a `ws` server attached with `{ server, path }` would 400 every other path's handshake. Upstream client: `@aws-sdk/client-transcribe-streaming`.
250
253
 
251
- - **Handshake**: login cookie (`isAuthenticated`, same as `/ws`) else 401; `ext` must be installed and declare `transcribe` else 403. The extension iframe is same-origin, so its WS carries the cookie.
254
+ - **Handshake**: login cookie (`isAuthenticated`, same as `/ws`) else 401; `ext` must be installed, switched on (`getExtension`) and declare `transcribe` else 403. The extension iframe is same-origin, so its WS carries the cookie.
252
255
  - **Upstream**: `StartStreamTranscription` (`pcm`, 16 kHz). `lang=auto` (default) → `IdentifyMultipleLanguages` + `LanguageOptions` = `ext-<id>.params.auto_languages` → manifest default → `zh-CN,zh-HK,en-US` (Cantonese must be a candidate, otherwise it is transcribed as wrong Mandarin text); one code (`^[a-z]{2}-[A-Z]{2}$`) → `LanguageCode`; a comma-separated list of 2–5 distinct codes → `IdentifyMultipleLanguages` + `LanguageOptions` = exactly that list (htrans sends `zh-CN,en-US` / `zh-HK,en-US` for its Mandarin + English / Cantonese + English options); anything else → `error bad-request`, upstream never called. "One dialect per language" in a list is left to Transcribe (its BadRequest maps to `bad-request`). Region `ext-<id>.params.region` → manifest default → `us-east-1`. Credentials: all three of `ext-<id>.secrets.access_key_id` / `secret_access_key` / `session_token` empty → the SDK default chain (`defaultProvider()`); both keys set → static keys (+ `session_token` if set); any other partial set → `error credentials` naming the missing key(s), upstream never called (no silent switch to the machine's identity). All read per connection.
253
256
  - **Frames**: client binary = PCM s16le mono 16 kHz, text `{"type":"end"}` = flush then server closes 1000. Server text: `ready` (sent on connect — the proxy is taking audio; it can't mean "Transcribe accepted" because the SDK's `send()` only resolves after the first audio event, so a client waiting for that would hit Transcribe's 15 s no-audio timeout; upstream rejections follow as `error`), `partial` / `final` `{start,end,text,lang}` (seconds from this stream's first audio byte), `error {code,message}` then close 1011. Codes: `credentials` (CredentialsProviderError / UnrecognizedClient / InvalidSignature / ExpiredToken / InvalidClientTokenId), `denied` (AccessDenied), `limit` (LimitExceeded), `bad-request` (BadRequest / bad `lang`), `io` (everything else, incl. > 30 s of audio queued unsent).
254
257
  - **Lifetime**: the client WS closing aborts the upstream request and destroys the client (no orphan streams). Frames are capped at 1 MiB (`maxPayload`); a protocol violation (oversized frame → close 1009, invalid UTF-8 text → 1007) closes only that socket — each connection has an `error` listener, and the upgrade router never throws on a malformed request-target (unknown path → `400 Bad Request`). Reconnect across Transcribe's 4 h cap / ~15 s silence drop is the extension's job. Logs: `[Transcribe] stream open ext=… lang=… region=…` / `stream closed after Ns` / `stream error … code=…` (error name only) — never audio or credentials.
@@ -270,7 +273,7 @@ The ipynb extension (2.0.0+) runs cells on **real kernels on the Halo machine**.
270
273
 
271
274
  An extension can ship agent skills next to its viewer: `~/.halo/global/extensions/<extId>/skills/<id>/` (`SKILL.md` + resource files + optional `config.yaml`, the ordinary skill layout). Discovery is by directory; the manifest has no skills field. `packages/server/src/agents/skill-sources.ts` is the one resolver: every skill reader goes through `listSkillDirs` / `skillSourceDir`. That covers session build / `activate_skill`, slash commands, the `/skill` verbs, `GET /skills`, Settings → Skills and evo's listing.
272
275
 
273
- - **Which folders count**: only extensions whose manifest passes the registry's own validator (`readExtensionDir`, verdict and entry path cached per manifest mtime, the entry file re-checked on every scan; an extension with no `skills/` subdirs is skipped without reading its manifest). A skill id must match `^[\w-]+$` and equal `<extId>` or start with `<extId>-`; anything else is ignored with one `[Skills] extension "<extId>": ignoring skills/<id> …` warning per manifest version, not per scan. If two extensions claim one id (`foo`'s `foo-bar` vs extension `foo-bar`), the lower extension id keeps it. Manifest `platforms` is not applied: it only decides where the viewer is offered, and an extension's skills load on every platform.
276
+ - **Which folders count**: only switched-on extensions (`.state.json`, read from disk like everything here — a switched-off extension's skills vanish from every reader at once and return when it is switched on; `extensionSkillIds` lists them so their settings aren't orphaned meanwhile) whose manifest passes the registry's own validator (`readExtensionDir`, verdict and entry path cached per manifest mtime, the entry file re-checked on every scan; an extension with no `skills/` subdirs is skipped without reading its manifest). A skill id must match `^[\w-]+$` and equal `<extId>` or start with `<extId>-`; anything else is ignored with one `[Skills] extension "<extId>": ignoring skills/<id> …` warning per manifest version, not per scan. If two extensions claim one id (`foo`'s `foo-bar` vs extension `foo-bar`), the lower extension id keeps it. Manifest `platforms` is not applied: it only decides where the viewer is offered, and an extension's skills load on every platform.
274
277
  - **Precedence**: workspace > global > extension. A higher source with the same id wholly overrides (the whole folder, no per-file fallback); the shadowed extension copy is still listed with `overridden: true`. To customise an extension skill, copy its folder into `~/.halo/global/skills/<id>/` or `<workspace>/.halo/skills/<id>/`.
275
278
  - **Scope `extension`, read-only**: `GET /skills` returns them with `scope: 'extension'` and `extId`. `POST` / `DELETE /skills` with that scope answer 403, so they never fall through to a same-id global skill. `/skill delete` refuses them, and evo neither lists nor patches them. The admin shows them in a Skills → **Extensions** section, rendered as a read-only file view with no editor, create or delete, and the subtitle "from extension X".
276
279
  - **Not auto-enabled**: an agent uses one only when its `skills:` lists the id (ticked in the agent form like any skill). An id whose extension is gone shows the usual "not installed" hint.
@@ -289,25 +292,68 @@ An extension documents itself; nothing it ships is injected into prompts or INDE
289
292
 
290
293
  ## `extension` skill and halo-hub
291
294
 
292
- Built-in skill `templates/skills/extension/` (in `BUILTIN_SKILL_IDS`; `TEMPLATE_VERSION` bumped to 69 in the same change): object command `/extension install|list|remove|models` (`models` = model provider configs, see the end of this section), `requiresAccess: full`. The built-in `default` agent lists it in `skills:` (since `TEMPLATE_VERSION` 81), so `/extension` shows in the command popup wherever the default agent is the built-in one. The payload reaches the body as `$ARGUMENTS`; when it names a source the agent installs right away without confirming, and it mentions the hub only when asked what exists or when an install fails. Everything goes through `templates/ext.sh` (curl + unzip + node, + git for tag-based hubs; no `gh`). `ext.sh install <id|zip|url>`: a zip path / URL is used as given, an id is fetched from the hub (below); then the same pre-check + six-step atomic replace as the server. It never talks to the server — the watcher picks the rename up within ~300 ms. The `halo` skill has a one-line pointer.
295
+ Built-in skill `templates/skills/extension/` (in `BUILTIN_SKILL_IDS`; `TEMPLATE_VERSION` bumped to 69 in the same change): object command `/extension install|list|remove|models` (`models` = model provider configs, see the end of this section), `requiresAccess: full`. The built-in `default` agent lists it in `skills:` (since `TEMPLATE_VERSION` 81), so `/extension` shows in the command popup wherever the default agent is the built-in one. The payload reaches the body as `$ARGUMENTS`; when it names a source the agent installs right away without confirming, and it mentions the hub only when asked what exists or when an install fails. Everything goes through `templates/ext.sh` (curl + unzip + node; no `gh`, no git). `ext.sh install <id|zip|url>`: a zip path / URL is used as given, an id is fetched from the hub (below); then the same pre-check + six-step atomic replace as the server. It never talks to the server — the watcher picks the rename up within ~300 ms. The `halo` skill has a one-line pointer.
293
296
 
294
- **Hub resolution.** The hub is the skill's `hub_repo` param (`config.yaml` → Settings → Skills → extension, value at `extension.params.hub_repo`), passed to `ext.sh` as `HALO_HUB_REPO`; empty / unset / an unsubstituted `{{…}}` placeholder = `https://github.com/turmind/halo-hub`, `owner/repo` = GitHub. The logic is self-contained `hub_*` functions and `ext.sh` is source-able (sourcing only defines them), so a later skill / workspace installer can reuse it. The platform comes from the host:
297
+ **Hub resolution (`install <id>`, since `TEMPLATE_VERSION` 108).** An id is read through the same [update.json protocol](#updates-updatejson) as the admin's update check — no release API, no platform detection, no git. `SKILL.md` runs `HALO_HUB_BASE='{{params.hub_base}}' HALO_HUB_REPO='{{params.hub_repo}}' bash EXT install <id>`; `hub_base()` takes the skill's `hub_base` param as given (trailing `/` dropped), else derives the GitHub raw `extensions/` directory on `main` from `hub_repo` (`https://raw.githubusercontent.com/<owner>/<repo>/main/extensions`; `hub_url` normalization: empty / unsubstituted `{{…}}` = `https://github.com/turmind/halo-hub`, `owner/repo` = GitHub), else nothing — a non-GitHub `hub_repo` with no `hub_base` stops with "no hub_base set …". The logic is self-contained `hub_*` functions and `ext.sh` is source-able (sourcing only defines them), so a later skill / workspace installer can reuse it.
295
298
 
296
- - `github.com` → GitHub; a host containing `gitlab` → GitLab (subgroups ok); `codeberg.org` or a host containing `gitea` / `forgejo` → Gitea/Forgejo; any other `https` host is probed (Gitea `/api/v1/repos/…`, then GitLab `/api/v4/projects/…`; a JSON answer identifies it).
297
- - On those platforms an id takes the newest non-draft, non-prerelease (GitLab: non-upcoming) release whose tag matches `^<id>-v\d` (so `glb` never picks a `glb-viewer-v…` tag), and its first `.zip` asset. Optional `GITHUB_TOKEN` (lifts the 60 req/h anonymous limit) / `GITLAB_TOKEN` (`PRIVATE-TOKEN`) / `GITEA_TOKEN` (`Authorization: token`) from the server environment are sent to the release API only.
298
- - An API error or "no release" on an identified platform stops with a hint (set the token, or download the zip from the releases page and `install <path>`). **Never a git fallback**: a git checkout of a build-step extension lacks its build output and would install broken.
299
- - Anything else (ssh, `ssh://`, `file://`, a local path, an https host that answered neither probe) → git mode: the highest `<id>-v<x.y.z>` tag by semver (prereleases skipped), shallow clone, `extensions/<id>/` minus the build inputs `pack.mjs` leaves out of a zip (`node_modules/`, `src/`, `package*.json`). Refused ("needs a build step") when the package has `fetch-deps.sh` / `build.sh` — a presence check, and all four current halo-hub extensions carry one, so a plain-git mirror of halo-hub installs none of them by id today.
299
+ - `hub_fetch_update`: `hub_base` must be https; every curl runs `--proto =https --proto-redir =https --max-redirs 5`. `<hub_base>/<id>/update.json` with `--max-time 10 --max-filesize 65536`, parsed by a node one-off with the server's rules (id, strict semver `version`, `url` resolved against the final redirected URL and https, 64-hex `sha256`, optional semver `minHalo`).
300
+ - `minHalo` → `hub_check_min_halo`: `${HALO_CLI:-halo} --version` with the build sha / `-dirty` dropped (the server's `haloBaseVersion` rule) and a semver compare; below → refuse with `<id> <ver> needs Halo X (this is Y) — upgrade Halo first: halo upgrade && halo server restart`; no readable version (no `halo` command, desktop, dev build) → a warning, and the install goes on.
301
+ - The zip: `--max-filesize 104857600 --connect-timeout 10 --max-time 600 --speed-limit 1024 --speed-time 30` (100 MB, 10 min, cut after 30 s under 1 KB/s); its sha256 must equal update.json's (`sha256 mismatch …`), and after unpacking the manifest id and version must equal update.json's (`manifest version … does not match update.json …`). Then the usual pre-check + atomic replace.
302
+ - curl uses the system CA store (`CURL_CA_BUNDLE` for a private CA); no token is sent anywhere on this path. Failures print a hint the skill relays (`no update.json for <id> at …` → wrong id or `hub_base`).
303
+ - Removed: the GitHub / Gitea / Forgejo / GitLab release-API pick and git tag mode for extensions. `hub_platform` / `hub_fetch_release` remain for `models` only (below).
300
304
 
301
- The hub repo keeps one directory per extension (`glb` / `ipynb` commit their vendored deps, `fetch-deps.sh` only refreshes them; `drawio` / `excalidraw` gitignore their build output and commit only the inputs), `scripts/pack.mjs extensions/<id>` → `dist/<id>-<version>.zip` with contents at the zip root (maintenance scripts and build inputs excluded; `pack.mjs` hand-mirrors the `registry.ts` manifest rules, and `packages/server/test/extension-manifest-rules-drift.test.ts` pins a hash of those rules and fails with a pointer to the hub copy when they change), and one release per tag `<id>-v<version>` carrying exactly one zip (a second one would be picked by accident). Skills and workspaces in the same repo are tagged `skill-<id>-v<ver>` / `ws-<id>-v<ver>`; nothing installs them yet. Catalog today (only `glb-v1.0.0` is released so far):
305
+ The hub repo keeps one directory per extension (`glb` / `ipynb` commit their vendored deps, `fetch-deps.sh` only refreshes them; `drawio` / `excalidraw` gitignore their build output and commit only the inputs), `scripts/pack.mjs extensions/<id>` → `dist/<id>-<version>.zip` with contents at the zip root (maintenance scripts and build inputs excluded; `pack.mjs` hand-mirrors the `registry.ts` manifest rules, and `packages/server/test/extension-manifest-rules-drift.test.ts` pins a hash of those rules and fails with a pointer to the hub copy when they change), and one release per tag `<id>-v<version>` carrying the zip; `pack.mjs` also writes `dist/<id>-<version>.update.json` (absolute release-download `url`, `sha256` of that zip, `minHalo` inherited from the committed copy), which is copied to `extensions/<id>/update.json` and committed once the release exists — that commit is what publishes the version to `install <id>` and the update check. Skills and workspaces in the same repo are tagged `skill-<id>-v<ver>` / `ws-<id>-v<ver>`; nothing installs them yet. Catalog — nine extensions, versions as named by each committed `extensions/<id>/update.json` (halo-hub `2f930ac`, every one released as `<id>-v<version>`):
302
306
 
303
307
  - `glb` 1.1.0 — `.glb .gltf .obj .stl`, `default`, read-only (`<model-viewer>` 4.3.1 offline bundle with Draco/Basis decoders; `.obj` / `.stl` converted in memory to GLB via three.js; `.gltf` must be self-contained — external `.bin` / textures → `error` frame; no `.mtl`).
304
- - `ipynb` 2.0.0 — `.ipynb`, `default`, `save`: notebook editor (markdown, KaTeX math, highlighted code, outputs incl. images / HTML tables; no widgets / JS outputs) that runs cells through the Jupyter proxy above when the host sends `init.jupyter` (Halo ≥ 1.6.1); older hosts edit and save without run controls.
305
- - `drawio` 1.0.0 — `.drawio .dio`, `default`, `['save']`; offline draw.io v32.0.2 (~22 MB zip); release-zip install only (build step).
306
- - `excalidraw` 1.0.0 — `.excalidraw`, `default`, `['save']`; offline Excalidraw 0.18 (~16 MB zip); release-zip install only (build step).
308
+ - `ipynb` 2.0.1 — `.ipynb`, `default`, `save`: notebook editor (markdown, KaTeX math, highlighted code, outputs incl. images / HTML tables; no widgets / JS outputs) that runs cells through the Jupyter proxy above when the host sends `init.jupyter` (Halo ≥ 1.6.1); older hosts edit and save without run controls.
309
+ - `drawio` 1.1.0 — `.drawio .dio`, `default`, `['save']`; offline draw.io v32.0.2 (~22 MB zip).
310
+ - `excalidraw` 1.1.0 — `.excalidraw`, `default`, `['save']`; offline Excalidraw 0.18 (~16 MB zip).
311
+ - `htrans` 1.0.4 — `.htrans`, `bundle`, `['media', 'transcribe']`, platforms `web` / `desktop-mac` / `desktop-win`: meeting recorder (mic + system sound, live captions through the [transcription proxy](#streaming-transcription-proxy), timed screenshots).
312
+ - `megadrive` 2.1.0 — `.mega`, `bundle`, `['fs-read']`: offline Mega Drive / Genesis (+ Master System / Game Gear) emulator (EmulatorJS + Genesis Plus GX); the ROM is picked anywhere on the machine and read in place. No ROMs included.
313
+ - `arcade` 2.2.0 — `.arcade`, `bundle`, `['fs-read']`: offline arcade emulator (EmulatorJS + FinalBurn Neo), romset `.zip` read in place the same way. No ROMs included.
314
+ - `halo-city` 1.0.2 — `extensions: []` + `activity`; `minHalo` 1.6.5: the [Halo City](halo-city.md) runtime visualizer as an activity-bar page.
315
+ - `voice-input` 1.0.0 — `extensions: []` + `chatbar`, `['media', 'stt-local']`, platforms `web` / `desktop-mac` / `desktop-win`; `minHalo` 1.6.5: dictation into the chat draft via the on-device [STT](stt-local.md) WS, inserted at the caret, never sent.
307
316
 
308
317
  `glb` is read-only, so active-only mount; `drawio` / `excalidraw` / `ipynb` are the real-world references for the save protocol.
309
318
 
310
- **`models` verb — model provider configs (since `TEMPLATE_VERSION` 84).** The same skill carries `/extension models` (and natural language "更新模型列表 / update the model list"): `ext.sh models update [--yes]` takes the hub's newest `models-v*` release (one zip with every provider yaml, `scripts/pack.mjs models` → `dist/models-<YYYY.MM.DD>.zip`), but only from an **https release-API hub** — a local path, plain git, ssh or `http://` hub is refused (extensions keep the rules above), and every curl runs with `--proto =https --proto-redir =https`. After the same zip safety checks (`safe_unzip`: no `..`, no absolute paths, no symlinks) it hands the unpacked directory to `${HALO_CLI:-halo} models install <dir> [--yes]`, which owns all validation and the write into `~/.halo/global/models.d/` (see [storage.md → Model registry format](storage.md#model-registry-format) and [guide/cli.md](../guide/cli.md#model-provider-configs-halo-models)); its exit code passes through — `3` = a new provider or changed endpoints, nothing written, the agent shows the list and re-runs with `--yes` only after the user agrees. `ext.sh models list` = `halo models list`. The server's `models.d` watcher reloads the registry and pushes `models:changed`.
319
+ **`models` verb — model provider configs (since `TEMPLATE_VERSION` 84).** The same skill carries `/extension models` (and natural language "更新模型列表 / update the model list"): `ext.sh models update [--yes]` takes the hub's newest `models-v*` release (one zip with every provider yaml, `scripts/pack.mjs models` → `dist/models-<YYYY.MM.DD>.zip`), but only from an **https release-API hub** — a local path, plain git, ssh or `http://` hub is refused (extension ids go through update.json instead, above), and every curl runs with `--proto =https --proto-redir =https`. After the same zip safety checks (`safe_unzip`: no `..`, no absolute paths, no symlinks) it hands the unpacked directory to `${HALO_CLI:-halo} models install <dir> [--yes]`, which owns all validation and the write into `~/.halo/global/models.d/` (see [storage.md → Model registry format](storage.md#model-registry-format) and [guide/cli.md](../guide/cli.md#model-provider-configs-halo-models)); its exit code passes through — `3` = a new provider or changed endpoints, nothing written, the agent shows the list and re-runs with `--yes` only after the user agrees. `ext.sh models list` = `halo models list`. The server's `models.d` watcher reloads the registry and pushes `models:changed`.
320
+
321
+ ## On / off
322
+
323
+ A switch in Settings → Extensions makes an installed extension act as not installed while keeping its files and its settings values. Before it, uninstalling was the only way to stop an extension, and that threw both away.
324
+
325
+ - **State**: `~/.halo/global/extensions/.state.json`, `{ "<id>": { "disabled": true } }`; an id without an entry is on. The file sits beside the extension dirs, not inside one, so an update (which swaps `<id>/`) keeps the switch. As a dot-named direct child of the root, its atomic rename trips the watcher (→ `extension:changed` to every admin), while the scanner, `ext.sh list` and skill-sources skip it. `extensions/state.ts` is the only reader / writer in the server (`disabledExtensionIds` / `setExtensionDisabled` / `forgetExtension`). Writes go through `init.ts` `writeFileAtomic`; reads are cached by inode + mtime + size, so the server, CLI / TUI and cron children see the same switch without sharing memory. A file that isn't a JSON object reads as all-on (one warning per change) and is never overwritten: a switch then answers 500 until the file is fixed or deleted.
326
+ - **Lifecycle**: a fresh install (no `<id>/` before) drops a leftover entry, so it starts on (`install.ts`, `ext.sh` `state_forget`). An update keeps the entry. Uninstall (route and `ext.sh remove`) drops it; a failed write there only warns, because the directory is already gone.
327
+ - **Switch**: `PATCH /api/extensions/:id { enabled }` sets the target state; a repeat writes and pushes nothing. Error rows have no switch (404).
328
+ - **What off means**: the snapshot lists the extension under `disabled`. Everything that *offers* an extension reads `extensions` / `getExtension` and skips it: preview resolution and Open with, activity-bar and chat-bar entries, its shipped skills (skill-sources), its settings section, the Transcribe / STT handshakes (403) and the STT model reconcile. What *serves* an already-open tab uses `getInstalledExtension`, so assets keep loading and a dirty tab can still save (banner "This extension has been turned off. Save your changes, then close the tab."). Its settings values stay, and `extensionOffNamespaces` keeps them out of Orphans. Settings → Extensions is the only list that shows an off extension; the fallback page names it for its suffix.
329
+ - **Older servers**: the admin's `setExtensionsSnapshot` defaults `disabled` to `[]`, since a server from before the switch sends none.
330
+
331
+ ## Updates (update.json)
332
+
333
+ Halo itself: notify only. An extension: one click. Code: `packages/server/src/updates/` (`update-json.ts` pure rules, `https-get.ts` the HTTP client, `extension-fetch.ts`, `core-check.ts`, `extension-check.ts`) + `routes/updates.ts` + the update route in `routes/extensions.ts`; wire types in `packages/core/src/protocol/update-types.ts`.
334
+
335
+ **update.json** — one small file per extension, read with a plain HTTPS GET from wherever it is hosted (git raw, S3, CDN, an intranet web server; no platform API, no paging):
336
+
337
+ ```json
338
+ { "id": "glb", "version": "1.2.0", "url": "glb-1.2.0.zip", "sha256": "<64 hex>", "minHalo": "2.1.0" }
339
+ ```
340
+
341
+ - `id` must equal the extension's. `version` is strict semver `x.y.z[-pre]` (the manifest rule: no `v`, no `+build`). `url` is required, absolute or relative; a relative one resolves against the URL the file was finally read from (after redirects) and must come out https. `sha256` is 64 hex in any case, lower-cased. `minHalo` is optional semver. Unknown fields are ignored so the format can grow.
342
+ - **Where**: manifest `source` when set, else `<hub_base>/<id>/update.json`. `hub_base` is the extension skill's param (Settings → Skills → extension). Empty means it is derived from `hub_repo` for a GitHub hub (`https://raw.githubusercontent.com/<owner>/<repo>/main/extensions`; empty `hub_repo` = turmind/halo-hub). A non-GitHub `hub_repo` with no `hub_base` gives `no-source`. Hub extensions carry no `source`, so a mirror only changes `hub_base`; a third-party extension hosted elsewhere sets `source`. To move a source, the old update.json points at a version whose manifest carries the new `source`.
343
+ - **Verdict** (`judgeUpdate`): `semver.gt(update.version, installed)` → `available`. The same or an older version → `current`, so a downgrade is never offered. Newer, but `minHalo` above this Halo's base version (`haloBaseVersion`: `HALO_VERSION` minus `-<sha>` and `-dirty`) → `needs-halo`, with no Update button. A dev build (no base version) is not held back by `minHalo`.
344
+ - **Read failures**: `not-found` (404 / 410), `invalid` (not JSON, over 64 KB, a wrong field or id), `refused` (a non-https URL, redirects included), `unreachable` (network error, timeout, any other non-2xx); plus `no-source`, and `unknown` before the first check.
345
+
346
+ **Fetching** (`https-get.ts`) — the one HTTP client for npm, update.json and the zip. https only, with every redirect hop re-checked, ≤ 5 redirects. `maxBytes` is enforced by Content-Length, then while streaming. One deadline covers the whole call, redirects included, plus an optional idle timeout. Redirect and non-2xx bodies are destroyed unread. update.json: 64 KB, 10 s. Zip: `MAX_ZIP_BYTES` (100 MB, the upload cap), 10 min in total, aborted after 30 s without a byte. No proxy support (node:https ignores `HTTPS_PROXY`, like every server-side fetch in Halo). A private CA needs `NODE_EXTRA_CA_CERTS`. Where GitHub is slow (mainland China), point `hub_base` at a mirror.
347
+
348
+ **Trust boundary.** None of these URLs (`source`, `hub_base`, the package URL) goes through the A2A URL policy (`a2a/url-policy`). Intranet https, S3 and CDN sources must work; the instance-metadata endpoint is http-only, which https-only already excludes; extensions are installed by an admin; and a response is only ever parsed as a strict update.json, never echoed back. What is checked: https, size, time and redirects; then the zip's sha256 against update.json, and the unpacked manifest's id + version against update.json (`installExtensionZip(zip, id, version)`); then the usual pre-check and atomic replace. The sha256 ties the zip to its update.json, not to a publisher: whoever controls the update.json controls the package, the same trust as installing that zip by hand.
349
+
350
+ **Halo itself** (`core-check.ts`): the npm dist-tags `latest` of `@turmind/halo` (~40 bytes), from `npm_config_registry` when it is https, else registry.npmjs.org. `state` is `current` / `available` (`semver.gt(latest, base)`) / `error` / `dev` (no base version, never checked) / `unknown`. Upgrading stays `halo upgrade && halo server restart`, run by the operator.
351
+
352
+ **Cadence** (each scope on its own). No timer anywhere. `GET /api/updates?scope=core|extensions` answers from the in-memory cache at once, and starts a check only when there is no result or it is ≥ 24 h old. `POST /api/updates/check {scope}` forces one unless the last finished < 60 s ago. One check runs per scope at a time; a started one reports `checking: true`, and its result is pushed as `updates:changed {scope, status}` to every admin socket. Failures (404 included) are cached like successes, so an offline server tries once a day. A restart empties the cache, and the next Settings visit asks again. The extensions scope reads every installed extension, on and off, 4 at a time, and caches the raw update.json result per id. After a watcher broadcast (install, update, remove, switch), `rejudgeExtensionUpdates` re-judges against the new snapshot and pushes only if the status changed, with no network. Behind the admin cookie, not on the public `/api/health`; not mounted in AgentCore mode.
353
+
354
+ **One-click update** (`POST /api/extensions/:id/update`). No body. The server takes the extension with `getInstalledExtension` (on or off), re-reads its update.json now (never the cached copy, nothing from the client), and goes on only on `available`: download → sha256 → `installExtensionZip(zip, id, version)` → `rescanAndBroadcast()`. The on / off state is outside `<id>/`, so an off extension stays off. One update per id runs at a time (an in-process set; a restart mid-update leaves only dot-named staging dirs, which the scanner skips). Codes: 400 bad id; 404 not installed; 409 one already running, or the verdict is `current` / `needs-halo` (`{ error, status }`); 502 the source or its package is at fault — read failed or no source (`{ error, status }`), download or sha256 failure, or an `ExtensionInstallError` such as an id / version mismatch; 500 anything else. The fresh read replaces the cache entry, so the re-judge after the install already counts the new version as current.
355
+
356
+ **Admin.** Settings → Extensions shows the verdicts and the Update button (see [MRU, keep-alive, upgrade, uninstall](#mru-keep-alive-upgrade-uninstall)); an open tab picks the new version up like any upgrade. The Settings sidebar foot (`core-update.tsx`) shows "Halo v<version>" + **Check for updates**, then one of: `Up to date (X)`; `New version X` with `halo upgrade && halo server restart` (copy button), "Under a systemd system unit, use sudo — see the deploy guide." and a Release notes link (`github.com/turmind/halo-agent/releases/tag/vX`); in the desktop app only "Download the new installer" (the releases page); for a dev build "Source build — not checked" and no button; an error as one grey line. No badge anywhere, and nothing is checked until Settings is opened. Both views subscribe to `updates:changed`; no polling.
311
357
 
312
358
  ## Extension capability boundary
313
359
 
@@ -324,7 +370,7 @@ Adding a capability: (1) extend `ExtensionCapability` + the `CAPABILITIES` white
324
370
  3. **No banner for an external change while dirty.** The host keeps the local edits silently and relies on the 409 (with the Overwrite confirm) on the next save.
325
371
  4. **Conflict UI is chained `confirmAction`s**, not a dedicated overwrite / discard / cancel dialog (see Dirty, save, conflict).
326
372
  5. **Uninstalling under a dirty tab** keeps the iframe alive with a banner — no confirm. A non-dirty tab shows the fallback page.
327
- 6. **Tests are not co-located**: `packages/admin/test/{extension-host-logic,extension-token,preview-registry,extension-activity,extension-chatbar}.test.ts`, `packages/server/test/{extensions-registry,extensions-install,extensions-routes,files-raw,extension-manifest-rules-drift,show-cookie-auth}.test.ts`.
373
+ 6. **Tests are not co-located**: `packages/admin/test/{extension-host-logic,extension-token,preview-registry,extension-activity,extension-chatbar,extensions-view}.test.ts`, `packages/server/test/{extensions-registry,extensions-install,extensions-routes,extensions-state,extensions-update-route,files-raw,extension-manifest-rules-drift,extension-hub,show-cookie-auth,update-json,updates-core,updates-extensions,updates-https,updates-routes}.test.ts`.
328
374
  7. **`allow-same-origin` is granted** (design and first cut withheld it). Required for deployments behind a cookie-auth proxy; see Host and the capability boundary.
329
375
  8. Smaller code-over-design points: the id regex is `ID_RE` (not `isSafeIdSegment`); staging dir is `.tmp-unpack-<rand>` (the id isn't known before unpacking); `snapshotKey` includes `installedAt`, so reinstalling the same version still broadcasts.
330
376
 
@@ -332,5 +378,6 @@ Adding a capability: (1) extend `ExtensionCapability` + the `CAPABILITIES` white
332
378
 
333
379
  - `file:changed` reaches an extension tab only after the admin sent a WS `subscribe` (it does when a session is selected). Opening a workspace without picking a session attaches no watcher, so an open viewer won't auto-refresh. Pre-existing behavior, not introduced here.
334
380
  - `PUT /files/raw`'s `expectMtime` must be the float `modifiedAt` from `GET /files/stat` (compared rounded); an integer-seconds × 1000 value 409s. The route is stat-then-write, not atomic.
335
- - Install is `.zip` upload or `ext.sh install` (release zip, or a tag in git mode) only: no marketplace, no online recommendations (the fallback page only links to the configured hub — `hub_repo` read from the settings schema, default turmind/halo-hub; a local-path / ssh hub is shown as plain text), no workspace-level install, no persisted "open with" choice, no backup of unsaved edits (MRU pinning is the only protection).
336
- - Hub tokens go to the release API only; the zip download carries none (asset links redirect to other hosts and `curl -L` would forward the header), so installing by id from a private hub may fail at the download.
381
+ - Install is `.zip` upload, `ext.sh install` (a zip path / URL, or an id via update.json) or the one-click update only: no marketplace, no online recommendations (the fallback page only links to the configured hub — `hub_repo` read from the settings schema, default turmind/halo-hub; a local-path / ssh hub is shown as plain text), no workspace-level install, no persisted "open with" choice, no backup of unsaved edits (MRU pinning is the only protection).
382
+ - No credentials on the update.json path: neither `install <id>` nor the server's check / update sends a token, so a private hub needs an update.json host that answers without one (an intranet server, a presigned or public S3 / CDN URL). Hub tokens go to the `models` release API only.
383
+ - No proxy for the server-side checks and updates (node:https ignores `HTTPS_PROXY`); `ext.sh` curl honors the usual proxy variables. A server that reaches the internet only through a proxy shows `unreachable` — use `hub_base` / `source` on a reachable host, or install the zip by hand.
@@ -29,7 +29,7 @@ Schedules are **durable** — the source of truth is the `cron_jobs` table; in-m
29
29
 
30
30
  The runner arms its own `setTimeout` toward each target in hops of at most 30 s; a hop that wakes early re-arms toward the **same** target, and the next target is computed (`nextRun(now)`) only after a fire — a sleep / clock jump over several fires runs once, no catch-up. Why not croner's own timers: in croner 10.0.1 a timer waking a fraction of a ms early re-plans from a fresh clock read, and when the clock crosses the fire instant mid-re-plan, an instance that has never fired (every job right after a restart, every one-shot) silently skips that fire — ~10% of first fires after a restart in production; the upstream fix is only in 11.0.0-dev (`test/cron-fire-race.test.ts`).
31
31
 
32
- - **Recurring**: standard cron expression (`job.schedule`), optional `timezone`, optional **active window** `[activeFrom, activeUntil)` (epoch ms, `active_from` / `active_until`, NULL = unbounded; `CRON_MIGRATIONS` slot 2). The window is passed to croner as native `startAt` / `stopAt` (`activeWindowOptions` in `runner.ts`; `startAt` is `activeFrom − 1 ms` because croner's startAt is exclusive), so fires outside it simply never happen — no `cron_runs` row. "Pause until X" = push `activeFrom` to X; there is no skip-list for a gap in the middle. Past `activeUntil` the job stays `enabled` (so it can be extended later) but `scheduleJob` creates no croner and logs `past activeUntil; not scheduled` once — the id goes into `_expired` so reconcile's "fingerprint cached but not in `_active`" retry branch treats it as handled instead of re-scheduling (and re-logging) every 10s. A schedule or timezone croner rejects is handled the same way: one `schedule invalid` warn per fingerprint (the timezone in it), retried once the row changes. REST validates the expression but not the timezone, and croner throws on a bad timezone only at `nextRun()`, so either can reach this from REST or an out-of-band write. The REST `nextRunAt` uses the same options, so the list shows the first post-pause fire; the admin badges a job "active from …" / "ended". Run-now ignores the window. Recurring only: a window together with `runAt` is rejected at every write surface (REST on the merged row, skill helper).
32
+ - **Recurring**: standard cron expression (`job.schedule`), optional `timezone`, optional **active window** `[activeFrom, activeUntil)` (epoch ms, `active_from` / `active_until`, NULL = unbounded; `CRON_MIGRATIONS` slot 2). The window is passed to croner as native `startAt` / `stopAt` (`activeWindowOptions` in `runner.ts`; `startAt` is `activeFrom − 1 ms` because croner's startAt is exclusive), so fires outside it simply never happen — no `cron_runs` row. "Pause until X" = push `activeFrom` to X; there is no skip-list for a gap in the middle. Past `activeUntil` the job stays `enabled` (so it can be extended later) but `scheduleJob` creates no croner and logs `past activeUntil; not scheduled` once — the id goes into `_expired` so reconcile's "fingerprint cached but not in `_active`" retry branch treats it as handled instead of re-scheduling (and re-logging) every 10s. A schedule or timezone croner rejects is handled the same way: one `schedule invalid` warn per fingerprint (the timezone in it), retried once the row changes. REST POST / PUT validate both: a non-string `timezone` → 400 `timezone must be a string`; a zone `Intl.DateTimeFormat` rejects → 400 `invalid timezone: <value>` (croner resolves zones through the same Intl call, so the two accept the same set; unset / `null` / `''` = host timezone). Only an out-of-band write reaches this path, and croner throws on a bad timezone only at `nextRun()`. Known gap: `manage-cron.py` stores `--timezone` unchecked (it resolves the zone through ZoneInfo only to parse `--active-from` / `--active-until`), so a typo there lands in this warn-once fallback. That's deliberate: Windows has no system tz database, so without the `tzdata` package ZoneInfo would reject valid zones. The REST `nextRunAt` uses the same options, so the list shows the first post-pause fire; the admin badges a job "active from …" / "ended". Run-now ignores the window. Recurring only: a window together with `runAt` is rejected at every write surface (REST on the merged row, skill helper).
33
33
  - **One-shot**: `job.runAt` (epoch ms) — the runner's timer fires once at that instant (no croner). After it completes, `finalize` sets `enabled=0` so it never re-fires. A `runAt` already in the past at schedule time is marked `lastRunStatus='missed'` and disabled (rather than firing immediately or retrying every reconcile) — unless its own run is still in flight (`_inflight`, e.g. `POST /api/cron/reload` mid-run): `finalize` then records the real status. At boot, the orphan sweep already finalized a one-shot whose run it found (`enabled=0`, `failed`), so the reload never sees it as missed. The `run_at` column is `CRON_MIGRATIONS` slot 0 in `db/cron-db.ts`, run through the shared `runMigrations` / `PRAGMA user_version` mechanism (see [storage.md](storage.md#schema-change-rules)).
34
34
  - **Folder + stateless** (`CRON_MIGRATIONS` slot 4, v5): `cron_jobs.working_dir` — the folder the run focuses on, passed as `halo cli --dir` (same semantics as a sub-agent's `working_dir`: the folder's `.halo/INSTRUCTIONS.md` chain joins the prompt, plus a `Working directory:` line); stored workspace-relative, NULL = the workspace root; validated at every write surface (inside the workspace, existing — REST re-checks a stored folder when `workspacePath` moves). `cron_jobs.stateless` (0/1) — **new session every run**: each fire gets a fresh root session `cron-<id>-<base36 ms>` (`perRunSessionId`, same no-double-prefix rule as `effectiveSessionId`), so nothing accumulates across fires (each job keeps only its own newest 10 runs; per-run sessions are deleted along with their run rows, see [Retention](#retention)); mutually exclusive with `session_id` (`400` on the merged row). `cron_runs.session_id` — the session that run used, the only record of a stateless run's id (NULL on rows from before v5). `cron_runs.workspace_path` (slot 5, v6) — the workspace the run fired against (the job's at fire time), i.e. where a stateless run's session lives even after the job moves workspace or is deleted; v6 backfills it from the job row, leaving NULL on rows whose job was already gone.
35
35
 
@@ -11,7 +11,7 @@ A vendor-neutral observability layer built on the official OpenTelemetry JS SDK.
11
11
  | `general.observability.endpoint` | string | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`). Traces, metrics and logs all exported over OTLP http/protobuf — `http://` or `https://`, on the collector's OTLP/HTTP port (4318 by default); gRPC (4317) not supported. Empty = off. Server: restart required; cron / evolution runs: next run. |
12
12
  | `general.observability.service_name` | string | `halo` | OTel resource `service.name`. Server: restart required; cron / evolution runs: next run. |
13
13
  | `general.observability.headers` | string, **secret** | `''` | Extra headers for every OTLP request, comma-separated `k=v` (e.g. `authorization=Bearer …`). Server: restart required; cron / evolution runs: next run. |
14
- | `general.observability.capture_content` | boolean | `false` | Put prompt / completion / tool argument+result text on spans (`gen_ai.*.messages` etc.). Off = metadata only (model, tokens, latency, tool names). Server: restart required; cron / evolution runs: next run. |
14
+ | `general.observability.capture_content` | boolean | `false` | Put prompt / completion / tool argument+result text on spans (`gen_ai.*.messages` etc.). Off = metadata only (model, tokens, latency, tool names). Env `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` overrides it (below). Server: restart required; cron / evolution runs: next run. |
15
15
 
16
16
  All four keys are `globalOnly`. The server's own exporter switches only on restart — `otel.ts` reads them once at boot, before anything else touches the OTel API. A `halo cli` / `halo tui` process reads them the same way at its own start, so a change reaches the next run — including the cron and evolution children the server spawns, which don't inherit the server's boot-time values (see the child-env rule below). A changed or cleared endpoint therefore reaches their next run without a server restart.
17
17
 
@@ -24,6 +24,8 @@ All four keys are `globalOnly`. The server's own exporter switches only on resta
24
24
  - `service_name` → `OTEL_SERVICE_NAME`
25
25
  - always `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf` (declarative — the `*-otlp-proto` exporters are hard-wired to it; this just makes an externally-loaded SDK agree on wire protocol)
26
26
 
27
+ `capture_content` goes the other way: it isn't mapped onto an env var; instead `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` (the OTel GenAI instrumentation's own switch), when exported as `true` / `false` / `1` / `0` (any case), beats the setting in both directions (`config.ts` `envBool`); unset or any other value → the setting. Read once at process start, like the rest. This is what lets a container (an AgentCore runtime) turn content capture on or off from its env alone, with no settings edit. A server that exports it passes it on to its cron / evolution children (it isn't in `settingsOtelEnvKeys`).
28
+
27
29
  No `OTEL_EXPORTER_OTLP_ENDPOINT` after the mapping → `enabled=false`, `initObservability()` returns without registering anything.
28
30
 
29
31
  **Child env.** Every key the mapping actually set (one that was unset before) is recorded in `settingsOtelEnvKeys` (`child-env.ts`), and `cleanChildEnv()` strips those keys from every child it builds. Inherited, the server's boot-time values would win a cron / evolution `halo cli` child's own `??=`, so a changed endpoint would never arrive and a cleared one would keep exporting until a server restart. Operator-exported `OTEL_*` keys aren't recorded and still pass through ("operator wins" holds in children too). Side effect: terminals, sandbox shells and the Jupyter server — also built from `cleanChildEnv()` — no longer inherit Halo's settings-derived collector config.
@@ -173,7 +175,7 @@ exporters:
173
175
 
174
176
  Prerequisites: CloudWatch Transaction Search must be enabled (spans land in the shared `aws/spans` log group, no resource policy needed); the log group `/aws/bedrock-agentcore/runtimes/<name>` must be created beforehand.
175
177
 
176
- Halo-side settings for this setup: `endpoint=http://localhost:4318`, `service_name=<name>`, `capture_content=true` (AgentCore Evaluations needs content to score against).
178
+ Halo-side settings for this setup: `endpoint=http://localhost:4318`, `service_name=<name>`, `capture_content=true` (AgentCore Evaluations needs content to score against). Or, from the runtime env alone: `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_SERVICE_NAME`, `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`.
177
179
 
178
180
  ## AgentCore Evaluations
179
181
 
@@ -12,7 +12,7 @@ Relay rides the same "deliver a session's wrap-up to some *other* session when i
12
12
 
13
13
  | | Relay |
14
14
  |---|---|
15
- | Back-pointer column | `agent_sessions.reply_to` on the target (JSON `{ workspace, sessionId }`) |
15
+ | Back-pointer column | `agent_sessions.reply_to` on the target (JSON `{ workspace, sessionId }`; A2A's `{ a2a }` and AgentCore's `{ invocation }` share it). Written through `takeReplyTo`: a pending `{ invocation }` job there is superseded first (its row reads `superseded`, `supersededBy: relay:<caller session>`), never silently overwritten |
16
16
  | Delivery hook | `deliverRelayReport`, in `runSession`'s finally right after `tryReportToParent` |
17
17
  | Quiet gate | root + no active children + empty queue (identical to `tryReportToParent`) |
18
18
  | Tool set | `buildRelayTools` (opt-in, full access): send / interrupt / stop / read / list |
@@ -61,7 +61,7 @@ No quiet gate (the session is by definition not done) and **`reply_to` is kept**
61
61
 
62
62
  ## What it deliberately doesn't do
63
63
 
64
- - **No cross-server reach.** Same `SessionManagerRegistry`, same process. Different server → [A2A](a2a.md), which reuses the same `reply_to` column (`{ a2a: taskId }`), quiet gate and interim doors.
64
+ - **No cross-server reach.** Same `SessionManagerRegistry`, same process. Different server → [A2A](a2a.md), which reuses the same `reply_to` column (`{ a2a: taskId }`), quiet gate and interim doors; so does AgentCore's `POST /invocations` (`{ invocation: id }`, [agentcore.md](agentcore.md#post-invocations)). A2A and relay both write theirs via `takeReplyTo`, which supersedes a pending `{ invocation }` job first.
65
65
  - **No session discovery beyond a flat root list.** `relay_list` returns a workspace's root sessions (id / agent / title / status, newest 100) so the secretary can reuse an existing conversation; it doesn't walk trees or search transcripts — that's the admin's job.
66
66
  - **No broadcast / fan-out helper.** The secretary calls `relay_send` once per department; the reports come back individually with the workspace + session id in the header, so it can tell them apart without extra bookkeeping.
67
67
  - **No ACL beyond full access.** A full-access secretary can reach every workspace on the box — that's the deployment's trust boundary already (it can `shell_exec` into them anyway).
@@ -191,7 +191,7 @@ The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the *
191
191
  **Sweep** (`sweepInterruptedRuns`, `agents/run-ledger.ts`): runs from the SessionManager constructor right after the retained legacy `sweepActiveGoals`, under the same ownership gate. `drainRunning(workspace)` reads and deletes that workspace's rows in one transaction **before** any nudge goes out — the invariant being that a nudge's own re-dispatch inserts a fresh row, which must never be mistaken for a leftover on some future sweep. Ids are grouped by root (`id.split('>')[0]`); each root gets one append-then-send nudge, worded by whether the root's **own** id is among the rows:
192
192
 
193
193
  - **Root mid-run** (text starts `[System] The server restarted at <ISO> while you were mid-turn…`): about the root's *own* turn first — history up to the last completed tool call is intact, the in-flight call was cut off and its side effects may or may not have landed (check before repeating anything destructive), continue the task from there; don't start over, don't wait for input. Sub-agents come second (stopped-but-revivable via `query_session`; not listed — the root's own transcript / `session_list` has them). The first version talked only about sub-agents, so a root killed mid tool-loop went hunting for stopped children, found old ones, and declared "nothing was interrupted" instead of resuming.
194
- - **Root idle, only sub-sessions mid-run** (text starts `[System] The server restarted at <ISO>. You were idle, but these sessions in your session tree were mid-run and were cut off…`): lists the cut-off ids of the root's tree — children and deeper (sorted, at most 10, then `(+N more)`) and says to re-dispatch with `query_session` if it was waiting on their reports, otherwise carry on. Sending the mid-turn text here sent an idle root looking for an in-flight tool call of its own that never existed. Skipped per root: row missing or `archivedAt` set; existing goal-mode bindings (a session with a `goal` column set, or a worker whose bound goal is still `running` — the retained `sweepActiveGoals` owns those nudges, unchanged); `cron-*` ids; `internal: true` agents (synchronous read of `agent.yaml`, since the sweep runs from a sync constructor). Principle: exactly one entry point gets woken per tree — a plain tree wakes its root, a `running` goal tree (existing binding) wakes its goal session, a cron tree wakes nobody (it waits for its next fire).
194
+ - **Root idle, only sub-sessions mid-run** (text starts `[System] The server restarted at <ISO>. You were idle, but these sessions in your session tree were mid-run and were cut off…`): lists the cut-off ids of the root's tree — children and deeper (sorted, at most 10, then `(+N more)`) and says to re-dispatch with `query_session` if it was waiting on their reports, otherwise carry on. Sending the mid-turn text here sent an idle root looking for an in-flight tool call of its own that never existed. Skipped per root: row missing or `archivedAt` set; existing goal-mode bindings (a session with a `goal` column set, or a worker whose bound goal is still `running` — the retained `sweepActiveGoals` owns those nudges, unchanged); `cron-*` ids — unless the root has a `reply_to`: then a caller waits on it (an AgentCore `/invocations` job run on a cron session id, which the `agentcore-deploy` skill passes; A2A; relay), it isn't a cli-driven cron tree, and it is nudged like a plain root; `internal: true` agents (synchronous read of `agent.yaml`, since the sweep runs from a sync constructor). Principle: exactly one entry point gets woken per tree — a plain tree wakes its root, a `running` goal tree (existing binding) wakes its goal session, a cron tree wakes nobody (it waits for its next fire).
195
195
 
196
196
  **Eager boot** (`index.ts`): rather than waiting for someone to open a workspace, right after constructing `SessionManagerRegistry` (before `bootChannels`) the server calls `listRunningWorkspaces()` and for each workspace with leftover rows whose `.halo` dir still exists: `claimWorkspaceRuntime(ws)` first — if another live server owns it, `continue` (rows stay for a later boot, and no SessionManager is built, since caching a non-owner SM would freeze "not owner" for the whole process lifetime) — otherwise `registry.getOrCreate(ws)`, which fires the same constructor chain (claim → reconcile → retained `sweepActiveGoals` → `sweepInterruptedRuns`). An interrupted root with nobody around is exactly the one that must nudge itself without a human opening the tab first.
197
197
 
@@ -30,6 +30,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
30
30
  │ ├── runs.db # Run ledger: (workspace, session_id) rows for server-driven runs in flight; steady state empty (running_sessions)
31
31
  │ ├── a2a.db # A2A: inbound tasks, push configs + outbox, outbound dispatches (see design/a2a.md)
32
32
  │ ├── extensions/<id>/ # Installed Canvas preview extensions (halo-extension.json + bundle)
33
+ │ ├── extensions/.state.json # Per-extension on / off switch, outside <id>/ so an update keeps it (see design/canvas-extensions.md)
33
34
  │ ├── stt-models/ # On-device STT model files, downloaded on demand (see design/stt-local.md)
34
35
  │ │ ├── sense-voice-zh-en-ja-ko-yue-2024-07-17/ # model.int8.onnx + tokens.txt (in-progress downloads: <file>.part)
35
36
  │ │ └── silero-vad-v4/silero_vad.onnx
@@ -74,10 +75,15 @@ Defines the persisted-data format for every Halo surface. Format changes must re
74
75
  ├── assets/<channel>/inbound/<accountId>/<date>/ # Inbound media per channel (image/voice/video/file)
75
76
  ├── runtime.lock # Workspace runtime ownership marker (pid) — see below
76
77
  ├── halo.db # Per-workspace sqlite (sessions metadata, disabled-items)
77
- ├── a2a.db # AgentCore mode only: A2A tasks + push outbox (instead of ~/.halo/global/a2a.db) — design/agentcore.md
78
+ ├── a2a.db # AgentCore mode only: A2A tasks + push outbox (instead of ~/.halo/global/a2a.db), `/invocations` jobs (agentcore_invocations) — design/agentcore.md
78
79
  ├── runs.db # AgentCore mode only: the run ledger (instead of ~/.halo/global/runs.db)
79
80
  ├── agentcore.lease # AgentCore mode only: single-writer heartbeat lease file
80
81
  └── docs/ # Project docs (requirements/design/dev/test/plans)
82
+
83
+ <base>/ # AgentCore session-dirs mode (HALO_AGENTCORE_SESSION_DIRS=1): HALO_WORKSPACE is a base, not a workspace
84
+ ├── .template/ # Deployer-synced seed for new session dirs (copied minus .halo/sessions, *.db + sidecars, runtime.lock, agentcore.lease*)
85
+ ├── .tmp-<16 hex>/ # A session dir being built (0700); renamed to <sid>/ when complete, never served
86
+ └── <sid>/ # One workspace per AgentCore runtime session id (owned 1000:1000 where chown is allowed), seen by its worker as /workspace
81
87
  ```
82
88
 
83
89
  **When `.halo/` is created.** `ensureWorkspaceHalo()` (`init.ts`) creates `sessions/ agents/ skills/ logs/ memory/ canvas/ tmp/ evo/{runs,applies,history}` and copies in `canvas/self.html`. It runs each time a directory is opened as a workspace: `POST /api/fs/workspace/resolve` (the admin folder picker), channel account binding and every inbound channel message (`resolveAccountWorkspace`), `/workspace switch`, every `getWorkspaceDb()` call, and `SessionManagerRegistry.getOrCreate()` on a cache miss only (a hit returns the manager built, and `.halo/` scaffolded, earlier in this process). `halo.db` appears the first time the workspace db is opened. `runtime.lock` appears when the server first claims the runtime. Read-only list routes (`GET /api/sessions/logs`, `GET /api/agent-configs`) call `hasWorkspaceHalo()` first. For a directory with no `.halo/` they return an empty or global-only list, so a list request never turns a directory into a workspace. The cron form's path field used to scaffold every real directory the user typed through. Show routes use `registry.peek()` for the same reason (see [halo-city.md](halo-city.md)). Any new read-only route that takes a workspace path should do the same.
@@ -636,7 +642,7 @@ The databases hold session metadata (the only home of title / counts / tokens /
636
642
  | stopped_at | INTEGER | Stopped timestamp (null = active) |
637
643
  | archived_at | INTEGER | Archived timestamp (null = not archived) |
638
644
  | goal / goal_session_id | TEXT | Retained legacy columns (goal mode binding JSON / back-pointer); existing bindings are unchanged and still honored |
639
- | reply_to | TEXT | Relay — JSON `{ workspace, sessionId }` of the caller session in another workspace; set by `relay_send`, cleared on report delivery. Added in `HALO_MIGRATIONS` slot (`db/index.ts`). |
645
+ | reply_to | TEXT | Relay — JSON `{ workspace, sessionId }` of the caller session in another workspace; set by `relay_send`, cleared on report delivery. Also `{ a2a: taskId }` (an inbound A2A task) and, in AgentCore mode, `{ invocation: id }` (an `/invocations` job, `agentcore_invocations` below). Added in `HALO_MIGRATIONS` slot (`db/index.ts`). |
640
646
  | title | TEXT | Session title, sticky once set |
641
647
  | exchange_count | INTEGER | Main user turns over the session's lifetime (kept + archived); new rows 0 |
642
648
  | context_tokens | INTEGER | Latest context size |
@@ -683,12 +689,27 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
683
689
  | feishu | `appId`, `appSecret`, `verificationToken`, `encryptKey`, `botOpenId` |
684
690
  | wecom | `botId`, `secret` |
685
691
 
692
+ **`agentcore_invocations`** — AgentCore `POST /invocations` jobs (lives in the workspace's `<ws>/.halo/a2a.db`, AgentCore mode only; [agentcore.md](agentcore.md#job-rows-a2adb))
693
+
694
+ | Column | Type | Notes |
695
+ |---|---|---|
696
+ | id | TEXT PK | Invocation id (UUID) — what `reply_to = { invocation }` points at |
697
+ | request_id | TEXT | Caller's idempotency `id`; partial unique index where not null |
698
+ | session_id | TEXT NOT NULL | The halo root session the job runs in |
699
+ | state | TEXT NOT NULL | `running` \| `ok` \| `error` \| `superseded` |
700
+ | created_at / finished_at | INTEGER | Unix ms; `finished_at` null while running |
701
+ | result | TEXT | Final reply text (`MEDIA:` lines kept), written at finish. Added in `A2A_MIGRATIONS` v2 |
702
+ | result_files | TEXT | JSON `[{ path, size }]` — the reply's `MEDIA:` files that existed at finish, never their bytes. Added in `A2A_MIGRATIONS` v2 |
703
+ | superseded_by | TEXT | What took this one's completion on the same session: a later job's invocation id, `a2a:<task id>` or `relay:<caller session id>`. Added in `A2A_MIGRATIONS` v2 |
704
+
705
+ Rows are never pruned.
706
+
686
707
  ### Schema change rules
687
708
 
688
709
  - `schema.sql` / each db's `CREATE_SQL` always describes the FULL current shape, so a fresh db is complete after the CREATEs alone
689
710
  - Every change to an already-existing db gets a numbered slot in that file's ordered migration list — `HALO_MIGRATIONS` (`db/index.ts`), `CRON_MIGRATIONS` (`db/cron-db.ts`), `A2A_MIGRATIONS` (`db/a2a-db.ts`); `channel-db.ts` / `evo-db.ts` / `runs-db.ts` currently pass an empty list. `runMigrations(sqlite, list)` (`db/migrate.ts`) runs `list[user_version..]` in order, each in its own transaction, stamping `PRAGMA user_version = i + 1` after each
690
711
  - A fresh db is also at `user_version 0` and runs the whole list, so every slot must be a no-op against the current shape (`addColumnIfMissing`, `CREATE … IF NOT EXISTS`). A CHECK constraint can't be altered in SQLite: the slot rebuilds the table (create `_v2`, copy rows, drop, rename) and skips when `sqlite_master` already shows the new constraint — `HALO_MIGRATIONS` v2 (`disabled_items.scope` admits `'extension'`) is the pattern
691
- - Current slots worth knowing: `HALO_MIGRATIONS` v2 — the `disabled_items` rebuild above (rows kept). `HALO_MIGRATIONS` v3 — `agent_sessions.output TEXT`, `agent_sessions.last_activity_at TEXT` (session store 2.0: the files carry no metadata). `CRON_MIGRATIONS` v5 — `cron_jobs.working_dir TEXT NULL` (job folder → `halo cli --dir`), `cron_jobs.stateless INTEGER NOT NULL DEFAULT 0` (1 = fresh session every run), `cron_runs.session_id TEXT NULL` (the session that run used). `CRON_MIGRATIONS` v6 — `cron_runs.workspace_path TEXT NULL` (the workspace the run fired against, where a stateless run's session lives), backfilled from the job row (`UPDATE … SET workspace_path = (SELECT workspace_path FROM cron_jobs …) WHERE workspace_path IS NULL`), so rows whose job is already gone stay NULL; column semantics in [cron.md](cron.md)
712
+ - Current slots worth knowing: `HALO_MIGRATIONS` v2 — the `disabled_items` rebuild above (rows kept). `HALO_MIGRATIONS` v3 — `agent_sessions.output TEXT`, `agent_sessions.last_activity_at TEXT` (session store 2.0: the files carry no metadata). `CRON_MIGRATIONS` v5 — `cron_jobs.working_dir TEXT NULL` (job folder → `halo cli --dir`), `cron_jobs.stateless INTEGER NOT NULL DEFAULT 0` (1 = fresh session every run), `cron_runs.session_id TEXT NULL` (the session that run used). `CRON_MIGRATIONS` v6 — `cron_runs.workspace_path TEXT NULL` (the workspace the run fired against, where a stateless run's session lives), backfilled from the job row (`UPDATE … SET workspace_path = (SELECT workspace_path FROM cron_jobs …) WHERE workspace_path IS NULL`), so rows whose job is already gone stay NULL; column semantics in [cron.md](cron.md). `A2A_MIGRATIONS` v1 — `a2a_tasks.result_files TEXT`; v2 — `agentcore_invocations.result`, `result_files`, `superseded_by` (all `TEXT`, for a table an earlier build created without them; a2a.db at `user_version` 2 after it)
692
713
  - Append only — never reorder or edit a shipped slot. A db opened by a newer halo (user_version > list length) logs a warning and continues
693
714
  - Do not drop or rename existing columns
694
715
  - New channel types: add rows with a new `channel_type` value + define config shape in the channel's `accounts.ts` adapter
@@ -709,3 +730,4 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
709
730
  | v1+ | 2026-10-10 | Model registry (additive): per-model `endpoints:`, `maxContextWindow`, `compressAt`; recommended `contextWindow` ≤ 272000; registry context / compressAt / verbosity defaults are scaffold-only; installer validates model fields and asks consent for URLs new to a provider. `HALO_MIGRATIONS` v2: `disabled_items.scope` admits `'extension'`. `CRON_MIGRATIONS` v5: `cron_jobs.working_dir`, `cron_jobs.stateless`, `cron_runs.session_id`. |
710
731
  | v1+ | 2026-10-13 | `CRON_MIGRATIONS` v6 (additive): `cron_runs.workspace_path`, backfilled from the job row (NULL where the job is already gone). |
711
732
  | v2 | 2026-10-15 | Session store 2.0: `{seg}.json` replaced by append-only `{seg}.ui.jsonl` (`add` / `patch` / `inflight` records) + `{seg}.raw.jsonl`; archive segments `{seg}.ui.arch.{N}.{h}.jsonl.gz`, committed count = segment files (no stored counter), archived at turn end over 5 MB; no metadata in files — `HALO_MIGRATIONS` v3 adds `agent_sessions.output` / `last_activity_at`, internal sessions get rows in `~/.halo/global/internal-sessions/halo.db`; `SessionMessage.compactSummary`. 1.x files are converted on first open (or `halo sessions migrate v1 v2`) and deleted after a read-back check. |
733
+ | v2 | 2026-10-17 | AgentCore `/invocations` (additive): `agentcore_invocations` in the workspace `a2a.db`; `A2A_MIGRATIONS` v2 adds `result`, `result_files`, `superseded_by`. `reply_to` gains `{ invocation }`. Session-dirs mode lays out `<base>/{.template,<sid>}/`. |
@@ -50,7 +50,7 @@ missing ──ensure()──▶ downloading{received,total} ──all files ok
50
50
  ### Triggers
51
51
 
52
52
  - **WS connect** (`routes/stt-ws.ts`): if the model isn't ready, `ensure()` runs, and that socket then receives `model` frames until `ready` or `error`.
53
- - **Extensions reconcile** (`extensions/watcher.ts` → `reconcileSttModels`): after `start()`'s first scan and after every `rescanAndBroadcast()` scan (install / uninstall / watcher event). If any valid extension declares `stt-local` and the state is neither `ready` nor `downloading`, it calls `ensure()`. This makes installing the extension the moment the ~240 MB fetch starts, and it retries an `error` on the next rescan. The watcher doesn't run in AgentCore mode.
53
+ - **Extensions reconcile** (`extensions/watcher.ts` → `reconcileSttModels`): after `start()`'s first scan and after every `rescanAndBroadcast()` scan (install / uninstall / watcher event). If any valid, switched-on extension declares `stt-local` and the state is neither `ready` nor `downloading`, it calls `ensure()`. This makes installing the extension the moment the ~240 MB fetch starts, and it retries an `error` on the next rescan. The watcher doesn't run in AgentCore mode.
54
54
 
55
55
  ## Engine
56
56
 
@@ -81,7 +81,7 @@ Each stream keeps a 30 s ring of raw samples (it must reach back past a 20 s seg
81
81
 
82
82
  `noServer` ws server behind `ws/upgrade-router.ts`, same structure as the Transcribe proxy (`routes/transcribe-ws.ts`).
83
83
 
84
- - **Handshake**: login cookie (`isAuthenticated(getTokenFromCookieHeader(cookie))`), else **401**. `ext` must be installed and declare `stt-local`, else **403**. Frames are capped at 1 MiB (`maxPayload`). Every socket has an `error` listener, so a protocol violation (oversize → 1009, bad UTF-8 → 1007) closes only that socket.
84
+ - **Handshake**: login cookie (`isAuthenticated(getTokenFromCookieHeader(cookie))`), else **401**. `ext` must be installed, switched on and declare `stt-local`, else **403**. Frames are capped at 1 MiB (`maxPayload`). Every socket has an `error` listener, so a protocol violation (oversize → 1009, bad UTF-8 → 1007) closes only that socket.
85
85
  - **Server → client** (text JSON):
86
86
  - `{"type":"model","state":"missing"|"downloading"|"ready"|"error","received"?,"total"?,"message"?}`: always the **first** frame. While the model isn't ready, more `model` frames follow (progress ≤ 2/s, plus every state change). `received` / `total` are present only for `downloading`, `message` only for `error`.
87
87
  - `{"type":"ready"}`: model ready **and** engine loaded. Audio is accepted from here on.
@@ -145,7 +145,8 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
145
145
  | `evolution:run_changed` / `evolution:apply_changed` | `evolution/ticker.ts` + `routes/evolution.ts` (broadcast) | Evolution run/apply state changed — drives the Evolution tab. See [evolution.md](evolution.md). |
146
146
  | `models:changed` | `models/watcher.ts` `reloadAndBroadcast` (broadcast to all clients) | `~/.halo/global/models.d/` changed (hub provider yamls installed by `halo models install` / `/extension models`, or removed by hand) and the merged registry differs from the last one — the server has already dropped its registry cache. No payload: the admin bumps its models bus, and the Agents panel / agent form, the composer's image-capability check and the Settings page re-fetch `/agent-configs/models` / the settings schema. Also bumped on WS reconnect (a frame lost while down). Global, no workspace marker. See [storage.md](storage.md#model-registry-format). |
147
147
  | `skills:changed` | `ws/skills-changed.ts` `notifySkillsChanged` (REST `POST` / `DELETE /skills`, `PATCH /skills/:id/toggle`, `/skill disable` · `enable` · `delete`) · `ws/watcher-pool.ts` (the workspace watcher saw `.halo/skills/**` change) | A skill was created / deleted / toggled, or a workspace skill folder changed on disk (an agent's `file_write`, a hand edit). No payload: the admin bumps its skill bus, and the chat slash-command popup, the Skills page and the agent form's skill picker re-fetch. Scoped on the server: a workspace skill or a toggle (the disable flag lives in that workspace's db) goes via `broadcastToWorkspace`, a global skill via `broadcast`. Only real changes emit: a no-op mutation (409 / 404 / already disabled) sends nothing, and the watcher path compares a per-workspace key (folder ids + each SKILL.md's frontmatter), so body / resource edits and the echo of a mutation that already notified stay silent. The watcher debounce is 300 ms on top of the file watcher's own 300 ms flush. `~/.halo/global/skills/` has no watcher, so a hand edit there shows on the Skills page's window-focus refetch. Also bumped on WS reconnect while the Skills page is open. |
148
- | `extension:changed` | `extensions/watcher.ts` `rescanAndBroadcast` (broadcast to all clients) | `~/.halo/global/extensions/` changed (install / upgrade / uninstall, from the admin upload, the `extension` skill, or a manual `mv`/`rm`) — carries the **full** `ExtensionsSnapshot` (`{extensions, errors}`), not a diff, and only fires when the snapshot key differs from the last one. The admin replaces its extension list wholesale: the preview registry re-resolves open tabs (an upgraded extension remounts, or shows a banner if the tab is dirty; an uninstalled one falls back to the "no built-in preview" page) and the Settings → Extensions panel re-renders. Global, no workspace marker — extensions are server-wide. See [canvas-extensions.md](canvas-extensions.md). |
148
+ | `extension:changed` | `extensions/watcher.ts` `rescanAndBroadcast` (broadcast to all clients) | `~/.halo/global/extensions/` changed (install / upgrade / uninstall / on-off switch, from the admin upload, the one-click update, the `extension` skill, or a manual `mv`/`rm`; a switch rewrites `.state.json`, which trips the same watcher) — carries the **full** `ExtensionsSnapshot` (`{extensions, disabled, errors}`: switched on, switched off, refused), not a diff, and only fires when the snapshot key differs from the last one. The admin replaces its extension list wholesale (a missing `disabled`, from an older server, reads as `[]`): the preview registry re-resolves open tabs from `extensions` only (an upgraded extension remounts, or shows a banner if the tab is dirty; an uninstalled or switched-off one falls back to the "no built-in preview" page, or keeps a dirty tab alive with a banner) and the Settings → Extensions panel re-renders. Global, no workspace marker — extensions are server-wide. See [canvas-extensions.md](canvas-extensions.md). |
149
+ | `updates:changed` | `updates/core-check.ts` · `updates/extension-check.ts` (broadcast to all clients) | An update check finished, or (extensions scope) a watcher rescan changed a cached verdict — `{scope: 'core' \| 'extensions', status}`, `status` = the same `CoreUpdateStatus` / `ExtensionsUpdateStatus` that `GET /api/updates?scope=…` returns. Checks start only from that GET (cache ≥ 24 h old) or `POST /api/updates/check`, never on a timer. The Settings sidebar foot (`core`) and Settings → Extensions (`extensions`) replace their status with it; nothing else listens. Global, no workspace marker. See [canvas-extensions.md → Updates](canvas-extensions.md#updates-updatejson). |
149
150
 
150
151
  ### `error` frames: optional `code`
151
152