@turmind/halo 1.4.6 → 1.5.1-alpha

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 (81) hide show
  1. package/README.md +0 -1
  2. package/admin-out/404.html +1 -1
  3. package/admin-out/_next/static/chunks/160.c52d3d6cf7bc8286.js +1 -0
  4. package/admin-out/_next/static/chunks/220.e250bbbf65d24094.js +1 -0
  5. package/admin-out/_next/static/chunks/362.a0c58d3bf9b1300b.js +1 -0
  6. package/admin-out/_next/static/chunks/449.c4b881b9ebdc556e.js +1 -0
  7. package/admin-out/_next/static/chunks/613.fb09e55e0a61d3fe.js +1 -0
  8. package/admin-out/_next/static/chunks/73.05abce37d312e880.js +1 -0
  9. package/admin-out/_next/static/chunks/803-7128009ee0e1c10e.js +1 -0
  10. package/admin-out/_next/static/chunks/839.a2729fd33013d763.js +1 -0
  11. package/admin-out/_next/static/chunks/868.bbb91afd48c9eb58.js +1 -0
  12. package/admin-out/_next/static/chunks/892.a35118e09d8d8efd.js +1 -0
  13. package/admin-out/_next/static/chunks/989-7420ee650083df43.js +1 -0
  14. package/admin-out/_next/static/chunks/app/page-fa5e935dbaa74165.js +1 -0
  15. package/admin-out/_next/static/chunks/webpack-1a264efd965db7ab.js +1 -0
  16. package/admin-out/_next/static/css/b8ffb5202b5a6619.css +3 -0
  17. package/admin-out/index.html +1 -1
  18. package/admin-out/index.txt +5 -5
  19. package/bundled-docs/design/agent.md +8 -7
  20. package/bundled-docs/design/architecture.md +9 -5
  21. package/bundled-docs/design/canvas-extensions.md +183 -0
  22. package/bundled-docs/design/cron.md +1 -1
  23. package/bundled-docs/design/express-self.md +9 -7
  24. package/bundled-docs/design/feishu.md +3 -1
  25. package/bundled-docs/design/observability.md +2 -0
  26. package/bundled-docs/design/prompt-system.md +6 -10
  27. package/bundled-docs/design/relay.md +22 -16
  28. package/bundled-docs/design/session.md +38 -26
  29. package/bundled-docs/design/slack.md +2 -2
  30. package/bundled-docs/design/storage.md +5 -5
  31. package/bundled-docs/design/telegram.md +4 -2
  32. package/bundled-docs/design/wechat.md +7 -5
  33. package/bundled-docs/design/wecom.md +2 -2
  34. package/bundled-docs/design/ws.md +29 -22
  35. package/bundled-docs/dev/add-model-provider.md +21 -18
  36. package/bundled-docs/dev/api.md +25 -24
  37. package/bundled-docs/dev/deploy.md +39 -9
  38. package/bundled-docs/dev/desktop-packaging.md +18 -8
  39. package/bundled-docs/dev/env.md +2 -3
  40. package/bundled-docs/dev/previews.md +9 -1
  41. package/bundled-docs/dev/tools.md +17 -82
  42. package/bundled-docs/guide/channels/wechat.md +1 -1
  43. package/bundled-docs/guide/chat.md +4 -2
  44. package/bundled-docs/guide/cli.md +0 -1
  45. package/bundled-docs/guide/delegation-and-access.md +1 -2
  46. package/bundled-docs/guide/workspace.md +11 -0
  47. package/bundled-docs/requirements/chat.md +11 -8
  48. package/bundled-docs/requirements/command.md +1 -12
  49. package/bundled-docs/requirements/editor.md +10 -0
  50. package/bundled-docs/requirements/settings.md +9 -0
  51. package/dist/index.js +2168 -1199
  52. package/package.json +2 -2
  53. package/templates/agents/deep-executor/agent.yaml +0 -3
  54. package/templates/agents/default/AGENT.md +0 -17
  55. package/templates/agents/default/agent.yaml +0 -1
  56. package/templates/agents/executor/agent.yaml +1 -1
  57. package/templates/models/aws-bedrock-claude-invoke.yaml +31 -0
  58. package/templates/models/aws-bedrock-mantle.yaml +42 -7
  59. package/templates/models/aws-bedrock-openai.yaml +53 -17
  60. package/templates/prompts/root/DELEGATION.md +58 -0
  61. package/templates/skills/extension/SKILL.md +70 -0
  62. package/templates/skills/extension/templates/ext.sh +112 -0
  63. package/templates/skills/halo/SKILL.md +3 -0
  64. package/templates/skills/send-file/SKILL.md +1 -1
  65. package/admin-out/_next/static/chunks/220.1ed70c874686122f.js +0 -1
  66. package/admin-out/_next/static/chunks/23-092ac3901411582e.js +0 -1
  67. package/admin-out/_next/static/chunks/362.5970ef15ec3d7b15.js +0 -1
  68. package/admin-out/_next/static/chunks/449.a403f7ad18054e3f.js +0 -1
  69. package/admin-out/_next/static/chunks/613.f314fc12e05a566c.js +0 -1
  70. package/admin-out/_next/static/chunks/73.a4617a551a8e176c.js +0 -1
  71. package/admin-out/_next/static/chunks/767.16a0b33b6f5e7965.js +0 -1
  72. package/admin-out/_next/static/chunks/839.e381287756453ffe.js +0 -1
  73. package/admin-out/_next/static/chunks/868.cc7fd84c9f2fd711.js +0 -1
  74. package/admin-out/_next/static/chunks/892.92b2ed455683b49a.js +0 -1
  75. package/admin-out/_next/static/chunks/989-6a0ec35b8ae2cb39.js +0 -1
  76. package/admin-out/_next/static/chunks/app/page-bf9c0b21ae431c92.js +0 -1
  77. package/admin-out/_next/static/chunks/webpack-ca926228f8cba1ff.js +0 -1
  78. package/admin-out/_next/static/css/c0ea31cd787f7aa7.css +0 -3
  79. package/bundled-docs/design/goal-mode.md +0 -144
  80. /package/admin-out/_next/static/{qYSILjqBjfgTggO0T8E7T → gRsZxPP4kTOmCtkHhJIZB}/_buildManifest.js +0 -0
  81. /package/admin-out/_next/static/{qYSILjqBjfgTggO0T8E7T → gRsZxPP4kTOmCtkHhJIZB}/_ssgManifest.js +0 -0
@@ -0,0 +1,183 @@
1
+ # Canvas preview extensions — installable file viewers in sandboxed iframes
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`, `packages/admin/src/features/editor/previews/{registry,extension-host,extension-host-logic,extension-token}.ts(x)`, `packages/server/templates/skills/extension/`.
4
+
5
+ ## Problem
6
+
7
+ The admin editor's preview set was compile-time: eight plugins registered in `previews/plugins/index.ts`, and every new file type (GLB, drawio, …) meant editing admin source, re-bundling and cutting a release — while each viewer's weight (model-viewer + three ≈ 1 MB) landed in every user's bundle. Extensions turn a viewer into **a static directory with an HTML entry** that the admin loads in an iframe and feeds file bytes over `postMessage`. Install = drop the directory into `~/.halo/global/extensions/<id>/`; no server restart, no page reload, no admin release.
8
+
9
+ ## Directory is the install
10
+
11
+ ```
12
+ ~/.halo/global/extensions/
13
+ ├── glb/ # dir name == manifest.id
14
+ │ ├── halo-extension.json
15
+ │ ├── index.html # manifest.entry
16
+ │ └── … # js / wasm / decoders / LICENSE
17
+ └── .tmp-unpack-a1b2c3/ # in-flight install (dot-prefixed → scanner skips it)
18
+ ```
19
+
20
+ - Valid manifest present = installed; directory gone = uninstalled. No db table, no registry file — the filesystem is the truth (same model as global skills). `init.ts` only `mkdir -p`s the root; no extension is preinstalled.
21
+ - **The server scanner is the only validator.** Whoever puts a directory there (admin zip upload, `ext.sh`, manual `cp -r`) gets the same verdict. A directory that fails validation is still **listed** as `{ id, error }` so the admin shows "installed but broken" (red row, removable) rather than "not installed".
22
+ - Ids are directory names *and* URL segments: `^[a-z0-9][a-z0-9_-]{0,63}$` (`registry.ts` `ID_RE`, deliberately tighter than the session-id `isSafeIdSegment`; `ext.sh` carries a copy — keep in sync).
23
+
24
+ ### Manifest (`halo-extension.json`)
25
+
26
+ | Field | Req | Rule |
27
+ |---|---|---|
28
+ | `id` | ✓ | `ID_RE`; **must equal the directory name** (`id mismatch` otherwise) |
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 |
31
+ | `extensions` | ✓ | non-empty; each `^\.[a-z0-9]+$` (dot, lowercase); matched against the lowercased file ext |
32
+ | `entry` | ✓ | relative HTML path, no leading `/`, no `..`; file must exist |
33
+ | `priority` | – | `default` (default; opens the file directly) \| `option` (open-with menu only) |
34
+ | `capabilities` | – | only `"save"` exists; **unknown value = manifest error** (reject rather than let the host silently drop a declared capability) |
35
+ | `description` / `homepage` / `license` | – | ≤ 200 chars / http(s) URL / free string, display only |
36
+
37
+ No `keepAlive` / `readonly` field: both are derived from `capabilities` (see MRU). No `minHaloVersion`: compatibility is the protocol version carried in `init`.
38
+
39
+ ### Atomic install (`install.ts`, mirrored by `ext.sh`)
40
+
41
+ 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.
42
+
43
+ ## Server
44
+
45
+ **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.
46
+
47
+ **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).
48
+
49
+ 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.
50
+
51
+ **Routes** (`routes/extensions.ts`, mounted under `/api`)
52
+
53
+ | Route | Auth | Notes |
54
+ |---|---|---|
55
+ | `GET /extensions` | cookie | `ExtensionsSnapshot` (valid + errors) |
56
+ | `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 |
57
+ | `DELETE /extensions/:id` | cookie | `rm -rf`; 404 if not installed |
58
+ | `GET /extensions/token` | cookie | `{ token, expiresAt }` — asset token, 24 h |
59
+ | `GET /extensions/:id/:version/:token/*` | **token in path** | static asset, below |
60
+
61
+ ### Asset serving and the scoped token
62
+
63
+ The iframe first shipped as `sandbox="allow-scripts"` **without** `allow-same-origin` — an opaque origin. Measured (Playwright): the document navigation itself still carries the admin cookie, but **every subresource** it loads — classic/module `<script>`, `<img>`, CSS, `fetch`, wasm, dynamic `import()`, `Worker` — is a cross-site request with **no cookie**; module scripts, `fetch` and wasm additionally need `Access-Control-Allow-Origin`. The original design assumed subresources would inherit the cookie; they don't. And a `?token=` query does not survive either: the document resolves `./viewer.js` against its own URL and drops the query. That is why the asset credential lives in the path. (The host has since added `allow-same-origin` — see Host, below — for a reason the token can't solve: a cookie-auth proxy in front of halo. The token stays the asset route's one auth path regardless.)
64
+
65
+ So the credential travels as a **path segment**, which relative URLs inherit for free:
66
+
67
+ 1. Admin calls `GET /api/extensions/token` (cookie-authed) → `mintScopedToken('ext', 24h)` = jwt `{ scope:'ext', iat, exp }` signed with the same secret as the login cookie.
68
+ 2. Iframe `src` = `/api/extensions/<id>/<version>/<token>/<entry>`; `./decoders/x.wasm` inside resolves to `…/<token>/decoders/x.wasm`.
69
+ 3. `authMiddleware` lets `EXTENSION_ASSET_PATH = /^\/api\/extensions\/[^/]+\/[^/]+\/[^/]+\/./` through without a cookie (≥ 4 segments, so it never overlaps list / install / token / `:id`); the route itself calls `verifyScopedToken(token, 'ext')` → else 401.
70
+ 4. **The token can't be replayed as a login.** `validateToken` — the cookie check, shared with the WS upgrade's `isAuthenticated` — returns null for any payload that has a `scope`. The token appears in URLs and access logs, so it is good for exactly one thing: reading extension static files (any installed extension's, nothing else).
71
+
72
+ `resolveAsset(id, version, rest)` returns null (→ 404) unless: `id` is valid and installed *without error*; `version` **equals** the installed version; no path segment is empty, `.`, `..`, dot-prefixed or contains `\`; the `realpath` stays inside the extension's real directory (symlink guard; both sides realpath'd because the root may itself sit under a symlink); the target is a regular file. Response headers: `Content-Type` from `assetMime` (core's image table + html/js/mjs/css/json/**wasm**/fonts/bin), `Access-Control-Allow-Origin: *`, `Referrer-Policy: no-referrer` (token stays out of `Referer`), `Cache-Control: public, max-age=31536000, immutable`. The version in the URL *is* the cache key — an upgrade changes it. A stale tab holding the old version gets 404, which is the "reload me" signal. No CSP is set here; an extension can ship its own `<meta>` CSP.
73
+
74
+ ### `PUT /files/raw` (save write-back)
75
+
76
+ `PUT /files` is utf-8 text only and `POST /files/upload` always writes under `.halo/uploads/`, so binary write-back got its own route in `routes/files.ts`: `?path=&projectId=[&expectMtime=]`, body = raw bytes, file **must already exist** (404 — save never creates), directories refused, path validated like the other file routes. If `expectMtime` is given and `Math.round(stat.mtimeMs) !== Math.round(expectMtime)` → `409 { error:'conflict', mtime, size }`; otherwise write and return `{ ok, path, mtime, size }`. The compare is a rounded float compare, so the baseline must be the `modifiedAt` from `GET /files/stat` (float ms).
77
+
78
+ ## Admin host
79
+
80
+ ### Subscribable registry and resolution (`previews/registry.ts`)
81
+
82
+ Two layers behind one lookup: `builtins` (the eight compile-time plugins) and `snapshot` (runtime extensions). Every mutation `bump()`s a version counter; `useRegistryVersion()` is a `useSyncExternalStore` over it, so `FilePreview` and the editor's MRU effect re-resolve on change. The snapshot is fed by `GET /extensions` at workspace open, each `extension:changed` frame, and a re-fetch on WS reconnect (`state-handlers.ts`) — **no polling**. `editor-panel.tsx`'s old module-level `BINARY_EXTENSIONS` snapshot is gone; `isBinaryExtension(ext)` reads the registry live.
83
+
84
+ **Initial load (`loadExtensions()`).** The page's one `GET /extensions` is a single shared promise: `workspace-layout.tsx` kicks it off in parallel with the workspace resolve, and the editor's one-shot routing awaits the same request. Until it lands, "list not loaded" looks exactly like "nothing installed", and a wrong text verdict opens — and on tab restore persists — the file as text (a reloaded `.glb` tab came back as text this way). So `isBinaryExtension(ext)` (click-open, tab restore after reload, open-to-side) is async: a *preview* verdict (`NON_TEXT_FALLBACKS` or `canPreview(ext)` already true) returns at once, since extensions only add viewers ahead of text and `FilePreview` re-picks the viewer when the list lands; a *text* verdict awaits `loadExtensions()` first, because an extension may claim an extension-only type (`.glb`) or a text suffix (`.json`). The wait is capped at `INITIAL_LOAD_WAIT_MS` (3 s), counted from the first call, so a request hung on a dead connection (sleep / wake) can't block every open; past the cap it routes as if nothing were installed (`.glb` opens as text). The request isn't cancelled — a late list still lands through `setExtensions` and bumps the version. It never rejects and is never re-issued: a failed fetch leaves the layer empty without delaying later opens, and the WS-reconnect re-fetch / `extension:changed` fill it in.
85
+
86
+ `resolve(ext)` returns every way to open the file, best first:
87
+
88
+ 1. `default` extensions, **newest `installedAt` first**
89
+ 2. the built-in plugin, if any
90
+ 3. `option` extensions (same ordering)
91
+ 4. `text` (Open as Text) — always present, always last
92
+
93
+ Errored extensions never appear. With nothing installed this is `[builtin, text]` / `[text]` — identical to pre-extension behavior, and the open-with menu (rendered only when `candidates.length > 2`) stays hidden, so **zero installs = zero visible change**. Deliberate difference from VS Code: with several `default` handlers VS Code asks and remembers the answer in `editorAssociations`; we have nowhere to remember it, so newest-install wins deterministically and the user switches via the menu. Ext-less files (`''` can't be in a manifest) always go to the text editor.
94
+
95
+ **Open with** (`ui/open-with-menu.tsx`, state in `FilePreview.Dispatch`): picking a candidate sets a per-tab `override` — never persisted, forgotten when the tab closes; it is looked up by `resolvedKey` in the live candidates so an upgraded extension renders its fresh `info`. Picking `text` calls the existing `onOpenAsText`. Switching away from a dirty extension asks `confirmAction("… Discard them and switch viewer?")`.
96
+
97
+ ### Iframe host (`extension-host.tsx` + `extension-host-logic.ts`)
98
+
99
+ The split is deliberate: `extension-host-logic.ts` is a **pure state machine** (`onClientFrame`, `onLoaded`, `onPutResult`, `onConflictChoice`, `onFileChanged`, `onSaveRequest`, `onThemeChange`), each taking `HostState` + one input and returning `{ state, effects[] }`; the React component only executes effects (`post`, `load`, `put`, `set-modified`, `error`, `confirm-conflict`, `warn`). That is what makes the protocol unit-testable frame by frame.
100
+
101
+ - Mount: `getExtensionToken()` → iframe `src` (`extension-token.ts`: module-level cache, in-flight dedupe, re-mint when < 60 s to expiry; an already-mounted iframe is never refreshed — it has its assets already).
102
+ - Attributes: `sandbox="allow-scripts allow-same-origin" allow="" referrerpolicy="no-referrer"` — the same grant as `html-preview.tsx`. `allow-same-origin` was withheld in the first cut (the path token made the cookie unnecessary for *halo*), but a cookie-auth reverse proxy in front of halo — the dev/prod CloudFront + midway Lambda, and equally oauth2-proxy / Cloudflare Access — authenticates **every** request by its own cookie, and an opaque-origin iframe sends none on module scripts / `fetch` / wasm (measured: classic `<script>` / `<img>` / CSS still carried the proxy's `SameSite=None` cookie; `mode: cors` requests carried nothing). The proxy 307'd `model-viewer.min.js` to its login page → the browser reported a CORS error → no `ready` → "extension unresponsive" after 10 s. Nothing halo-side can add a cookie to those requests; only `allow-same-origin` makes them first-party again. Trade-off accepted: the extension now runs in the admin's origin and can reach `parent.document` and the cookie-authed API — same boundary as an HTML preview, trust model "code the user chose to install" (like a skill).
103
+ - Sender check is `e.source === iframe.contentWindow` plus the `haloExt: 1` marker on every frame. `e.origin` is the admin's own origin for every extension iframe and can't distinguish two extensions.
104
+ - The extension never fetches the file: the host fetches `viewUrl` (streaming, no 10 MB cap) as an `ArrayBuffer` plus `GET /files/stat` for the mtime baseline, and **transfers** the buffer in `load`. `targetOrigin` is `'*'` (kept from the opaque-origin days; the frame only carries the file bytes the extension is about to be shown anyway).
105
+ - No `ready` within 10 s → "extension unresponsive" with Retry / Open as Text. `error` frame → banner above the iframe. Files > 100 MB (`EXTENSION_MAX_BYTES`, client memory) show the too-large placeholder instead of mounting.
106
+
107
+ ### postMessage protocol v1
108
+
109
+ Types in `packages/core/src/protocol/extension-frames.ts` (`EXTENSION_PROTOCOL_VERSION = 1`), shared with hub extensions. All frames `{ haloExt: 1, type, … }`.
110
+
111
+ | Dir | `type` | Fields | Semantics |
112
+ |---|---|---|---|
113
+ | ext→host | `ready` | `protocol` | listener installed; host sends nothing before it. v1 host doesn't read `protocol` (field reserved for negotiation) |
114
+ | host→ext | `init` | `protocol`, `file{name,path,size,ext}`, `capabilities`, `theme` | once, right after `ready`; `capabilities` = what the host grants |
115
+ | host→ext | `load` | `buffer`, `mtime` | file bytes (transferred); resent when the file changes on disk and the doc isn't dirty → "replace current document" |
116
+ | ext→host | `dirty` | `dirty` | needs `save`; otherwise ignored + `console.warn` |
117
+ | host→ext | `save-request` | – | Save button pressed and doc dirty; ext answers `save` |
118
+ | ext→host | `save` | `buffer` | no `save` capability → `save-error{denied}`, no network; a save while one is in flight is dropped |
119
+ | host→ext | `saved` | `mtime` | PUT succeeded; ext clears dirty, `mtime` is the new baseline |
120
+ | host→ext | `save-error` | `reason: conflict\|denied\|io`, `message`, `mtime?` | ext stays dirty; UI is the host's |
121
+ | host→ext | `theme` | `theme` | on admin theme switch; may be ignored |
122
+ | ext→host | `error` | `message` | can't handle the file; host shows message + Open as Text / Download |
123
+
124
+ ```
125
+ host extension
126
+ |-- iframe src=…/<token>/entry --->|
127
+ |<--------- ready{protocol:1} -----|
128
+ |-- init{file,capabilities,theme} >|
129
+ | fetch(viewUrl) + stat |
130
+ |-- load{buffer,mtime} ----------->| render
131
+ |<--------- dirty{true} -----------| markModified(path) → tab dot
132
+ | user clicks Save |
133
+ |-- save-request ----------------->|
134
+ |<--------- save{buffer} ----------|
135
+ | PUT /files/raw?expectMtime=… |
136
+ |-- saved{mtime} ----------------->| 200 (409 → conflict flow below)
137
+ ```
138
+
139
+ ### Dirty, save, conflict
140
+
141
+ - `dirty` mirrors into the editor store (`markModified` / `clearModified`), so the tab dot and the existing close-tab `confirmAction` work unchanged. Unmounting or reloading with a dirty doc clears the dot with it.
142
+ - Save trigger is the toolbar **Save** button → `requestSave` → `save-request`; `editor-panel.handleSave` also forwards to the mounted host via `getExtensionHost(projectId, path)` (a module-level registry keyed by panel + path, same shape as `face-bridge.ts`). 5 s without a `save` reply → alert "did not respond".
143
+ - `file:changed` for an extension tab is routed by `editor-panel` to `host.fileChanged()` → stat → `onFileChanged`: ignored when `diskMtime <= state.mtime` (our own save's echo) or when dirty; otherwise re-`load`.
144
+ - **409**: `onPutResult` emits `confirm-conflict`. `confirmAction` is yes/no, so the three-way choice is two chained questions — *Overwrite the disk version?* yes → re-PUT once with the 409's mtime as `expectMtime` (`retried`; a second 409 → `save-error{conflict}` + banner); no → *Discard your changes and reload?* yes → re-`load`, no → cancel (stay dirty). IO failure → `save-error{io}` + banner, stays dirty.
145
+
146
+ ### MRU, keep-alive, upgrade, uninstall
147
+
148
+ - `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`: 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.
149
+ - **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).
150
+ - **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.
151
+ - **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).
152
+ - **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.
153
+
154
+ ## `extension` skill and halo-hub
155
+
156
+ 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`, `requiresAccess: full`. Everything goes through `templates/ext.sh` (curl + unzip + node; no `gh`; optional `GITHUB_TOKEN` lifts the 60 req/h anonymous limit). `ext.sh install <id|zip|url>`: an id resolves to the newest non-draft, non-prerelease GitHub release of `turmind/halo-hub` whose tag starts with `<id>-v`, and takes its first `.zip` asset; 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.
157
+
158
+ The hub repo keeps one directory per extension that is *identical to the installed directory* (no build step, `cp -r` is a valid install), `scripts/pack.mjs extensions/<id>` → `dist/<id>-<version>.zip` with contents at the zip root, and one release per tag `<id>-v<version>` carrying exactly one zip (a second one would be picked by accident). Today: `glb` (`<model-viewer>` 4.3.1 offline bundle with Draco/Basis decoders, `.glb`, `default`, read-only, so active-only mount) and `echo-save` (`.echo`, `option`, `['save']` — the minimal reference for the save protocol).
159
+
160
+ ## Extension capability boundary
161
+
162
+ An extension **can**: receive one file's bytes and metadata; render in its own iframe; load static assets from its own directory via the path token; report `error`; receive `theme`; with `save`, report `dirty` and hand back bytes for the host to write to **that same file**.
163
+
164
+ An extension **cannot** (sandbox + protocol): open popups, downloads, forms or navigate the top window; use camera / mic / autoplay (`allow=""`); add admin UI (toolbar, commands, sidebar); talk to other iframes. The **protocol** gives it no way to read or write any workspace file except the open one (no `readRelative` / `listDir`). Since the host grants `allow-same-origin` (see Host), the sandbox no longer isolates it from the admin's origin: a hostile extension *could* script `parent.document` or call the cookie-authed `/api/*` (measured: `fetch('/api/extensions')` from inside → 200). That is the accepted trust model — installing an extension is like installing a skill — not a gap to patch per route. **Outbound network is not blocked** — no CSP is injected, so `fetch('https://…')` works where the remote allows CORS; trust model is "code the user chose to install" (same as a skill), and hub policy is offline-capable extensions. A hard block would be one `Content-Security-Policy` header on the asset route, no protocol change.
165
+
166
+ Adding a capability: (1) extend `ExtensionCapability` + the `CAPABILITIES` whitelist in `registry.ts`; (2) add the frame type in `extension-frames.ts`; (3) gate it in `extension-host-logic.ts` (undeclared → ignore + warn, like `save`); (4) if it needs the server (e.g. reading a sibling file), the **host** calls `/api/files/*` — an extension never touches the API directly. Install, scan, watcher and registry stay untouched.
167
+
168
+ ## Deviations from the original design
169
+
170
+ 1. **Install / delete are not full-access-only.** The admin cookie carries no access level, so list / install / delete / token are all "any logged-in admin". Only the agent-side `extension` skill keeps `requiresAccess: full`.
171
+ 2. **Ctrl/Cmd+S does nothing on an extension tab** — Monaco isn't mounted, so there's no global shortcut. The toolbar Save button is the only trigger.
172
+ 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.
173
+ 4. **Conflict UI is chained `confirmAction`s**, not a dedicated overwrite / discard / cancel dialog (see Dirty, save, conflict).
174
+ 5. **Uninstalling under a dirty tab** keeps the iframe alive with a banner — no confirm. A non-dirty tab shows the fallback page.
175
+ 6. **Tests are not co-located**: `packages/admin/test/{extension-host-logic,extension-token,preview-registry}.test.ts`, `packages/server/test/{extensions-registry,extensions-install,extensions-routes,files-raw}.test.ts`.
176
+ 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.
177
+ 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.
178
+
179
+ ## Known limitations
180
+
181
+ - `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.
182
+ - `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.
183
+ - Install is `.zip` upload or `ext.sh install` only: no marketplace, no online recommendations (the fallback page links to halo-hub statically), no workspace-level install, no persisted "open with" choice, no backup of unsaved edits (MRU pinning is the only protection).
@@ -99,7 +99,7 @@ interface CronChannelDispatcher {
99
99
  - **`MEDIA:` attachments**: the runner passes raw stdout down untouched; `dispatchToTargets(rawText, targets, workspacePath)` extracts the agent's `MEDIA:<path>` marker lines once, then decides **per target** (one run can mix both kinds of channel):
100
100
  - `supportsMedia: true` (wechat, slack) → marker-stripped text + a `CronMedia { paths, workspacePath }`; the channel sends real file uploads, one `DispatchResult` row per attachment. Both skip the text send when a marker-only run leaves it empty.
101
101
  - no `supportsMedia` (telegram, feishu, wecom) → the **original text with `MEDIA:` lines intact** — the attachment degrades to a visible path instead of silently vanishing.
102
- - `CronMedia.workspacePath` is the **job's** workspace (where the run produced its files), not the target account's — it's the sandbox root for `isMediaPathAllowed` (paths outside workspace/OS-tmp are rejected with a failed result row).
102
+ - `CronMedia.workspacePath` is the **job's** workspace (where the run produced its files), not the target account's — it's the sandbox root for `isMediaPathAllowed` (paths outside workspace/OS-tmp are rejected with a failed result row). Cron passes no access level, so the `full`-account relaxation the chat handlers get does not apply here — the job workspace + OS tmp rule stays.
103
103
  - Wiring telegram/feishu up for real requires refactoring first, not copying: telegram's file send is an inline `sendPhoto/…/sendDocument` switch in `handler.ts`'s responder; feishu's `sendFeishuMedia` is anchored to an inbound `messageId` a cron run doesn't have. Extract the send-to-chat half in the channel module, then flip `supportsMedia: true` (details in each `cron-dispatcher.ts` header).
104
104
  - **Per-channel size limits are the dispatcher's job**, not the runner's: wechat splits the text at `WECHAT_TEXT_LIMIT` (3500 chars, shared `splitText` from `channels/shared/chunk.ts`) and sends the chunks in order — the ilink gateway rejects any single `sendmessage` over 16 KB with `ret=-2`. A failure on chunk *i* of *n* is recorded as `chunk i/n: <error>` in the target's `DispatchResult`, so the admin run row shows how much of the report already landed (a `failed` row no longer means "nothing arrived"). Telegram still sends one shot (Bot API caps at 4096) — same class of gap, unfixed.
105
105
  - **`chatId` semantics**: when a job is created from inside a chat, the target pins to that conversation (`chatId` set). When unset, the channel's own fan-out / default-recipient logic decides (e.g. Telegram fans out to its whitelist; Slack/Feishu/WeCom require an explicit `chatId`). WeCom is the one dispatcher with **no HTTP send path**: it borrows the handler's live long-connect `WSClient` (`liveClients`), so a stopped / kicked / auth-exhausted account fails the target loudly (`wecom long-connect not active`) instead of queueing.
@@ -27,11 +27,13 @@ The marker is **stripped from rendered chat** so the user sees only the face mov
27
27
 
28
28
  **Seeding:** `init.ts` calls `ensureWorkspaceHalo()` on workspace open, force-copying the canonical engine from `packages/server/templates/canvas/self.html` to `<workspace>/.halo/canvas/self.html`.
29
29
 
30
- **Runtime expression:** Agent emits `<<<SHOW: payload >>>` in a reply. On turn completion:
31
- 1. `chat-handlers.ts:maybeHandleShow()` detects all `<<<SHOW:[\s\S]*?>>>` markers (non-greedy, global)
32
- 2. For each match, extracts the payload (trimmed)
33
- 3. Calls `postToFace(payload)` to forward it
34
- 4. Deduplicates by `${msgId}#${occurrenceIndex}` so queue drains don't fire twice
30
+ **Runtime expression:** Agent emits `<<<SHOW: payload >>>` in a reply. On `chat:complete`:
31
+ 1. `chat-handlers.ts` takes the round's replies with `takeRoundReplies()` (`chat-store.ts`) — every main assistant bubble since the previous `chat:complete`, in log order, each handed out once. A round can span several bubbles (the head an interjection split off, a turnId split, the follow-up answering a queued message), so scanning only the last bubble would miss earlier markers
32
+ 2. `maybeHandleShow(replies)` detects all `<<<SHOW:[\s\S]*?>>>` markers (non-greedy, global) in those bubbles; `maybeHandleCapture(wsClient, replies)` looks for `<<<CAPTURE>>>` in the same set
33
+ 3. For each match, extracts the payload (trimmed)
34
+ 4. Calls `postToFace(payload)` to forward it
35
+
36
+ **No dedup set.** `takeRoundReplies()` advances a cursor (`roundStart`) past what it returned, so the duplicate `chat:complete`s a queue drain emits find no new bubbles and fire nothing. `setMessages` (history load, snapshot restore, reattach) and `clear()` reset the cursor to the end of the loaded log, so markers in already-loaded history never fire.
35
37
 
36
38
  **Forwarding:** `face-bridge.ts:postToFace()` posts to every registered iframe:
37
39
  ```javascript
@@ -87,7 +89,7 @@ Provided by `self.html` from line 432 onwards (`const self = {…}`). All expres
87
89
 
88
90
  - **Engine template:** `packages/server/templates/canvas/self.html` — particle field, mode switching, API surface, voice audio graph. ~630 lines. Canonical source; force-copied to every workspace on open.
89
91
  - **Skill instruction:** `packages/server/templates/skills/self/SKILL.md` — teaches the agent when/how to use the face.
90
- - **Marker detection:** `packages/admin/src/shared/ws-handlers/chat-handlers.ts:maybeHandleShow()` (lines 38–50) — regex match `<<<SHOW:([\s\S]*?)>>>` on turn completion, deduplicate by message ID + occurrence index.
92
+ - **Marker detection:** `packages/admin/src/shared/ws-handlers/chat-handlers.ts:maybeHandleShow()` — regex match `<<<SHOW:([\s\S]*?)>>>` on the round's replies at `chat:complete`; the replies come from `takeRoundReplies()` in `chat-store.ts`, which hands each bubble out once.
91
93
  - **Iframe registration:** `packages/admin/src/features/editor/face-bridge.ts` — module-level registry of mounted previews; `postToFace()` forwards payloads via `postMessage`.
92
94
  - **Preview component:** `packages/admin/src/features/editor/html-preview.tsx` — sandboxed iframe with `allow-scripts` + `allow-same-origin` and `allow="autoplay"` (so `self.voice` audio, triggered by postMessage rather than a click, isn't gated), calls `registerFaceIframe()` on mount.
93
95
  - **Marker stripping:** `packages/admin/src/shared/components/message-list.tsx:TextBlock()` (line 430) — strips both `<<<CAPTURE>>>` and `<<<SHOW:...>>>` before rendering.
@@ -114,7 +116,7 @@ The marker is a **verbatim pipe** — Halo never parses or validates the payload
114
116
  - **Scope:** The code runs in a function closure with `self` as the API surface: `(new Function('self', code))(self)`
115
117
  - **Errors:** Non-greedy pattern `[\s\S]*?` handles newlines and nested `>` characters. Malformed lines silently no-op (caught in try/catch).
116
118
  - **Order:** Multiple markers in one reply are extracted in sequence and forwarded in order; scene beats queue and play sequentially
117
- - **Deduplication:** Each `${msgId}#${occurrenceIndex}` fires exactly once across duplicate queue-drain events
119
+ - **Once-only:** Each marker fires exactly once — `takeRoundReplies()` hands every bubble out a single time, so duplicate queue-drain `chat:complete` events find nothing new, and markers in loaded history never fire
118
120
  - **Window:** Markers are dropped silently if no face preview is open; the registry is empty so `postToFace()` has no targets
119
121
 
120
122
  ### Trust model — why `new Function` is acceptable here
@@ -115,7 +115,7 @@ The SDK wraps protobuf marshalling; we just register an `EventDispatcher` callba
115
115
  7. Strip mention markup from text (Feishu inserts `<at user_id="ou_xxx">@bot</at>` or `@_user_123` tokens)
116
116
  8. Slash command dispatch (p2p only — group threads don't benefit from `/session new`, `/session list`, etc. since each thread is already its own session)
117
117
  9. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)), which does the rest:
118
- - Create or retrieve the active session for this thread (with inherited access level) + apply the goal-mode route overlay
118
+ - Create or retrieve the active session for this thread (with inherited access level)
119
119
  - If the session is compacting → reply "⏳ 正在整理上下文,请稍后再发消息(通常 30 秒内完成)"; if busy → "已收到,会在当前轮结束后处理。" — hint only, the message is delivered either way
120
120
  - Refresh the reply route (`{inboundMessageId, isP2P, chatId}`) and attach the `FeishuResponder` listener once (it reads the route lazily at send time)
121
121
  - `sm.sendUserMessage(sessionId, agentInput, images?)` with `[channel: feishu | user: <id> | thread: <rootId>]` context
@@ -170,6 +170,8 @@ trailing chunks of a long reply were dropped.
170
170
  - `.mp4/.mov/.webm/.m4v/.avi` → `uploadFile(fileType: 'mp4')` → `msg_type: 'media'` (with optional cover frame)
171
171
  - everything else → `uploadFile(fileType: 'stream')` → `msg_type: 'file'`
172
172
 
173
+ Path whitelist: a `full` account may send any readable path; other access levels are limited to the workspace or the OS temp dir (`isMediaPathAllowed(path, workspace, accessLevel)`). A blocked path throws inside the same try as the upload, so it surfaces as the `t('handler.upload_failed')` reply rather than a silent drop.
174
+
173
175
  Inbound images are downloaded via `/im/v1/messages/{messageId}/resources/{imageKey}?type=image`, decrypted if needed, saved under `<workspace>/.halo/assets/feishu/inbound/<accountId>/<date>/`, and fed to vision.
174
176
 
175
177
  ## Message routing (thread vs p2p)
@@ -83,6 +83,7 @@ Child of `invoke_agent`, one per model call — created retroactively on the `us
83
83
  | `gen_ai.usage.input_tokens` | ✓ | |
84
84
  | `gen_ai.usage.output_tokens` | ✓ | |
85
85
  | `gen_ai.response.finish_reasons` | `["tool_use"]` or `["end_turn"]` | |
86
+ | `halo.ttft_ms` | streamed calls only — ms from request start to the first delta (text / thinking / tool-args), same number as the admin `ttft` badge | |
86
87
  | `gen_ai.input.messages` | | semconv JSON (see below) — **delta only**: messages appended since this turn's previous `chat` span (first call → the user message; later calls → that cycle's `tool_result`s) |
87
88
  | `gen_ai.output.messages` | | semconv JSON, only the trailing assistant message |
88
89
 
@@ -116,6 +117,7 @@ Exported every 15s via `PeriodicExportingMetricReader`. Same scope as the spans.
116
117
  |---|---|---|---|
117
118
  | `gen_ai.client.token.usage` | histogram | `{token}` | `gen_ai.request.model`, `gen_ai.token.type=input\|output` |
118
119
  | `gen_ai.client.operation.duration` | histogram | s | `gen_ai.request.model`, `gen_ai.operation.name=chat` |
120
+ | `gen_ai.server.time_to_first_token` | histogram | s | `gen_ai.request.model` — streamed calls only; semconv's recommended buckets (1ms … 10s) instead of the SDK's ms-scale defaults |
119
121
  | `halo.tool.duration` | histogram | s | `gen_ai.tool.name` |
120
122
  | `halo.turn.duration` | histogram | s | `outcome=ok\|error` |
121
123
  | `halo.model.retries` | counter | — | `kind=<retry kind>` |
@@ -106,7 +106,7 @@ interface MdContents {
106
106
  [system-prompts.ts](../../../packages/server/src/prompts/system-prompts.ts) keeps the hard-coded defaults for seeding and fallback.
107
107
 
108
108
  ### Seed (init.ts startup hook)
109
- Startup seeds `templates/prompts/{bootstrap,all,root}/` into `~/.halo/global/prompts/` (currently `BOOTSTRAP.md`; `TOOL_GUIDELINES.md`, `TOOL_SHELL[.windows].md`, `WORKSPACE_CONVENTIONS.md`, `RUNTIME.md`; `WORKSPACE_MEMORY.md`). These are **platform-owned, force-overwritten** on template refresh (`TEMPLATE_VERSION` gate) — user customization belongs in the workspace `prompts/` override, not in the global copies.
109
+ Startup seeds `templates/prompts/{bootstrap,all,root}/` into `~/.halo/global/prompts/` (currently `BOOTSTRAP.md`; `TOOL_GUIDELINES.md`, `TOOL_SHELL[.windows].md`, `WORKSPACE_CONVENTIONS.md`, `RUNTIME.md`; `DELEGATION.md`, `WORKSPACE_MEMORY.md`). These are **platform-owned, force-overwritten** on template refresh (`TEMPLATE_VERSION` gate) — user customization belongs in the workspace `prompts/` override, not in the global copies.
110
110
 
111
111
  ### Live load (every `buildAgentInstance`)
112
112
  `loadSystemPrompts(workspaceRoot?)` resolves each scope directory with workspace > global precedence:
@@ -186,7 +186,7 @@ Not injected: USER.md / `prompts/root/` / `prompts/bootstrap/`.
186
186
 
187
187
  The self-evolution agents (`__evo_agent__`, `__score__`, `__apply_agent__`) are platform tooling, not workspace-resident assistants. They get **none** of the workspace context: USER.md, INSTRUCTIONS.md (global + workspace-root), INDEX.md, and all three prompt scopes (`prompts/all` / `root` / `bootstrap`) are cleared in `composeSystemPrompt`. Only their own AGENT.md (which carries the full procedure) plus the tool list remains — this keeps their token budget clean.
188
188
 
189
- > Caveat: `prompts/all` is where `TOOL_SHELL.md` (platform shell guidance) lives, so a `shell_exec`-capable internal agent (`__apply_agent__`) doesn't inherit it. Both `__apply_agent__`'s and `goal`'s (the goal-mode judge, also an internal agent) AGENT.md carry their own "Shell usage & platform" section to cover this — keep that in mind if adding shell to another internal agent.
189
+ > Caveat: `prompts/all` is where `TOOL_SHELL.md` (platform shell guidance) lives, so a `shell_exec`-capable internal agent (`__apply_agent__`) doesn't inherit it. `__apply_agent__`'s AGENT.md carries its own "Shell usage & platform" section to cover this — keep that in mind if adding shell to another internal agent.
190
190
 
191
191
  ### Fallback
192
192
 
@@ -204,7 +204,7 @@ If `mdPrompt` is empty:
204
204
 
205
205
  **Root and sub-agents both get a roster** — there's no `isRoot` gate on *whether* a roster appears (any agent with a non-empty `team` gets one). Runaway re-subcontracting used to be stopped by a blanket "root only" ban; it's now bounded by the per-agent `team` whitelist (which is also the delegation switch) plus `maxNestingDepth` (default 16). This lets a sub-agent legitimately delegate further (grandchild sessions) when its `agent.yaml` declares a team, while the depth cap and whitelist keep cascades finite.
206
206
 
207
- **Facts, not policy.** The block states who is reachable and how (`start_session` / `query_agent`) and explicitly defers *when and how much to delegate* to the workspace INSTRUCTIONS. Root and sub-agents get the identical block — an earlier version gave roots an orchestrator pep-talk ("prefer delegation", "fan out", "don't poll", a self-spawn cost warning) that could contradict whatever stance a workspace's INSTRUCTIONS.md took; delegation policy now lives in exactly one place.
207
+ **Facts, not policy.** The block states who is reachable and how (`start_session` / `query_agent`) and explicitly defers *when and how much to delegate* to the workspace INSTRUCTIONS. Root and sub-agents get the identical block — an earlier version gave roots an orchestrator pep-talk ("prefer delegation", "fan out", "don't poll", a self-spawn cost warning) that could contradict whatever stance a workspace's INSTRUCTIONS.md took; delegation policy now lives in exactly one place. (The workspace-neutral part — what fits a sub-session, the three checks, the brief checklist — is root-scope `prompts/root/DELEGATION.md`, see [Final injection order](#final-injection-order); it states criteria, not a stance.)
208
208
 
209
209
  **Team whitelist = the delegation switch.** `agent.yaml` carries an optional `team: [id, …]`. A **non-empty** list is what *enables* delegation — it grants the whole session-tool bundle plus the roster (see `canDelegate`) AND restricts reach to exactly those ids. **Unset or empty `[]` means the agent cannot delegate at all** (no session tools, no roster) — this is a breaking change from the earlier "unset = every agent reachable" default; agents authored before this change that relied on the implicit-all behavior must now list their team explicitly. The `isTeamMember(team, targetId)` predicate still gates the three reach surfaces consistently — the roster (what the agent sees), `start_session`, and `query_agent` (server-side enforcement, so a hand-crafted call to a non-team agent is rejected). Self gets no special-casing: include the agent's own id to allow parallel self-spawn (the seed `default` agent lists `default`), omit it to block self-spawn — exactly like any other agent.
210
210
 
@@ -234,12 +234,6 @@ Tail-append:
234
234
  Your available tools: <tool1>, <tool2>, .... Only use tools in this list.
235
235
  ```
236
236
 
237
- ### Self-review (`draft`)
238
-
239
- When the YAML `tools:` lists `draft`, `resolveBaseToolSet` ([session-manager.ts](../../../packages/server/src/agents/session-manager.ts)) builds the self-review tool via `createDraftTool()` and stashes its per-turn `reset` on the session (`session.draftReset`). It carries no prompt text of its own beyond the tool description — the reflection contract lives in the tool's `tool_result` (an adversarial review checklist), not in the system prompt.
240
-
241
- It exists because a plain-text answer is single-pass: the agent loop only re-calls the model on a `tool_use` block, and `thinking` runs before the answer in the same call. `draft` lets the model materialise its answer as a tool call, get back a hostile-reviewer checklist, and critique the now-concrete draft on the next call. Opt-in per agent (no global switch); bounded to 3 rounds/turn by a closure counter reset at each turn-attempt. Full mechanics in [dev/tools.md](../dev/tools.md#self-review-tool).
242
-
243
237
  ## Final injection order
244
238
 
245
239
  ### Root agent
@@ -254,7 +248,7 @@ AGENT.md ← workspace > global
254
248
  ## Project Knowledge ← <ws>/.halo/INDEX.md (or nudge)
255
249
  "The project workspace is at: ..."
256
250
  "Working directory: ..." ← if workingDir is set
257
- rootPrompt ← prompts/root/*.md (ws > global)
251
+ rootPrompt ← prompts/root/*.md (ws > global): DELEGATION.md, WORKSPACE_MEMORY.md
258
252
  allPrompt ← prompts/all/*.md (ws > global)
259
253
  <available_skills> ← if yaml.skills non-empty
260
254
  Your available tools: ...
@@ -262,6 +256,8 @@ Your available tools: ...
262
256
 
263
257
  root-scope leads all-scope: root-only orchestrator guidance lands while attention is high, the generic tool layer trails (same rationale as the roster riding behind AGENT.md).
264
258
 
259
+ `DELEGATION.md` (root-only) carries the delegation criteria — what fits a sub-session vs. doing it yourself, three checks before `start_session` — and the brief checklist (goal + done criteria, decisions made in the conversation, known facts, boundaries, verification). It is **not gated on `canDelegate`**: every root reads it, including roots with no `team`; the file's opening sentence ("This applies when your tools include `start_session`") scopes it. Workspace-specific routing (which agent for what, size thresholds) stays in the workspace INSTRUCTIONS, and the `## Your Team` roster stays a plain factual list.
260
+
265
261
  (A sub-agent's `working_dir` directory-chain INSTRUCTIONS.md sit in the `## User Instructions` region — plain markdown, right after the global/ws-root layer — see Sub-agent below. A user's `@scope` injects per-turn into the message, not here — below.)
266
262
 
267
263
  ### Sub-agent
@@ -8,46 +8,52 @@ Halo runs many workspaces on one server — one per team / project / credential
8
8
 
9
9
  ## Mechanism
10
10
 
11
- Relay mirrors goal mode's shape exactly, because goal mode had already solved "deliver a session's wrap-up to some *other* session when it's genuinely done":
11
+ Relay rides the same "deliver a session's wrap-up to some *other* session when it's genuinely done" seam that sub-agent auto-reports use — `runSession`'s finally:
12
12
 
13
- | | Goal mode | Relay |
14
- |---|---|---|
15
- | Back-pointer column | `agent_sessions.goal_session_id` on W | `agent_sessions.reply_to` on the target (JSON `{ workspace, sessionId }`) |
16
- | Delivery hook | `deliverGoalRound` in `runSession`'s finally | `deliverRelayReport`, same finally, right after it |
17
- | Quiet gate | root + no active children + empty queue | identical |
18
- | Tool set | `buildGoalTools` (G only) | `buildRelayTools` (opt-in, full access): send / interrupt / stop / read / list |
19
- | Reach | same workspace | any workspace via `SessionManagerRegistry` |
13
+ | | Relay |
14
+ |---|---|
15
+ | Back-pointer column | `agent_sessions.reply_to` on the target (JSON `{ workspace, sessionId }`) |
16
+ | Delivery hook | `deliverRelayReport`, in `runSession`'s finally right after `tryReportToParent` |
17
+ | Quiet gate | root + no active children + empty queue (identical to `tryReportToParent`) |
18
+ | Tool set | `buildRelayTools` (opt-in, full access): send / interrupt / stop / read / list |
19
+ | Reach | any workspace via `SessionManagerRegistry` |
20
20
 
21
21
  **Dispatch** (`relay_send` / `relay_interrupt` share one `dispatch(params, hard)`): resolve the target workspace (`realpathSync`, must contain `.halo/`) → `registry.getOrCreate(wsPath)` gives the foreign `SessionManager` → create the session if missing (`relay_interrupt` refuses instead: nothing to interrupt) → `writeReplyTo(target db, session_id, { workspace: caller ws, sessionId: caller session })` → `appendUserMessage` (UI transcript, raw text) + `sendUserMessage` (model-facing, prefixed `[channel: relay | from: <caller ws>]`). The prefix reuses the channel-tag convention the system prompt already teaches ("don't echo the tag"), so the target agent knows the message is agent-sourced without a new prompt rule.
22
22
 
23
23
  **Hard interrupt** adds one step: if `sendUserMessage` returned `queued` (target was busy), call `target.interruptSession(session_id)`. Order is enqueue-then-abort, same as `querySession(interrupt=true)` — the aborted turn's finally sees a non-empty queue, so it drains into the message instead of firing a relay report for the half-done turn.
24
24
 
25
- **Delivery** (`deliverRelayReport(host, session)`, runs at *every* root turn end in every workspace): cheap `reply_to` column read first (no-op for the 99.9 % of sessions without one) → subtree-quiet gate → body = `finalOutput || output`, `[RELAY TARGET ABORTED …]` prefix when `turnError` is set (before the cap, so the marker survives) → cap at `limits.autoReportMax` with a `relay_read(...)` pointer → `clearReplyTo` → `registry.getOrCreate(reply_to.workspace)` → `appendUserMessage` + `sendUserMessage` on the caller. Append-then-send is the same pair the run-ledger nudge uses: `sendUserMessage` alone never writes the UI transcript, so the secretary's admin view would show a reply to nothing.
25
+ **Delivery** (`deliverRelayReport(host, session)`, runs at *every* root turn end in every workspace): cheap `reply_to` column read first (no-op for the 99.9 % of sessions without one) → subtree-quiet gate → body = `finalOutput || output`, `[RELAY TARGET ABORTED …]` prefix when `turnError` is set (before the cap, so the marker survives; for an account error — 401/402/403, bad key, no balance — the closing hint says to fix the target's model configuration and not to resume it with `relay_send`, since a re-send fails the same way) → cap at `limits.autoReportMax` with a `relay_read(...)` pointer → header `[Relay report · workspace <ws> · session <id> · status: completed]` (`status: aborted` when `turnError` is set) → `clearReplyTo` → `registry.getOrCreate(reply_to.workspace)` → `appendUserMessage` + `sendUserMessage` on the caller. Append-then-send is the same pair the run-ledger nudge uses: `sendUserMessage` alone never writes the UI transcript, so the secretary's admin view would show a reply to nothing.
26
26
 
27
27
  ## Invariants and why
28
28
 
29
- - **One dispatch, one *final* report.** `reply_to` is cleared *before* the send. A delivery failure (caller workspace unreachable) can't re-fire on the next turn end, and — more importantly — a user who later chats directly in the department workspace never pings the secretary. The secretary can always re-`relay_send` if a report went missing. The one extra message is the **interim report** (below) — it never clears `reply_to`, so it doesn't consume the final one.
29
+ - **One dispatch, one *final* report — interims may precede it.** `reply_to` is cleared *before* the send. A delivery failure (caller workspace unreachable) can't re-fire on the next turn end, and — more importantly — a user who later chats directly in the department workspace never pings the secretary. The secretary can always re-`relay_send` if a report went missing. The only other messages are **interim reports** (below), one per answered turn that another turn follows, plus one when the run ends on an answer while a child still holds the final report back (deferred interim, below) — they never clear `reply_to`, so they don't consume the final one. The header's `status:` field tells them apart (`still running` vs `completed` / `aborted`).
30
30
  - **Nested trees report once.** The quiet gate is the same one `tryReportToParent` uses, so a target that fans out three sub-agents delivers exactly one relay report, after the last child bubbles up — not one per child turn end.
31
31
  - **Full access only.** `session-agent-builder` injects the set only when `nameSet.has('relay_send') && accessLevel === null`. A sandboxed (`workspace` / `readonly`) session can't be given a tool that opens other workspaces' sqlite and session trees; listing the name in such an agent's yaml is silently ignored.
32
32
  - **Name-gated bundle.** Only `relay_send` is recognised in `tools:`; the other four (`relay_interrupt` / `relay_stop` / `relay_read` / `relay_list`) ride along. Prevents a half-configured agent that can send but never read back / stop. The admin picker mirrors this with one chip (`GET /agent-configs/tools` builds it from `buildRelayTools` against a dummy target and rewrites the description to name the full set).
33
- - **Server only.** `setRelayRegistry` is called once in `index.ts`; the CLI / TUI never call it, so `getRelayRegistry()` is `null` there — every tool returns `{"code": 1, "error": "relay is unavailable in this runtime (server only)"}` and `deliverRelayReport` logs a warning and returns. No CLI code path knows relay exists.
33
+ - **Server only.** `setRelayRegistry` is called once in `index.ts`; the CLI / TUI never call it, so `getRelayRegistry()` is `null` there — every tool returns `{"code": 1, "error": RELAY_UNAVAILABLE}` (`relay.ts`: "relay is not available in the CLI / TUI — it only runs inside halo server", permanent for this runtime, do not retry; send from the admin UI / an IM or Web channel, or open the target with `halo tui -w <workspace path>`) and `deliverRelayReport` logs a warning and returns. No CLI code path knows relay exists. Note the builder does **not** check the registry before injecting, so a CLI / TUI session (and every cron run, which is a `halo cli` child) still *sees* the relay tools when its agent lists `relay_send` — the error is permanent there, not a transient outage; route cross-workspace dispatch through a server-side channel (admin / Web / IM / `halo acp`) instead.
34
34
  - **Target-side transcript is normal.** The department workspace's session looks like any other root session in its Sessions tab (user message, agent reply); the only trace of relay is the `reply_to` row while a dispatch is pending. Nothing in the target's system prompt changes.
35
35
  - **Same workspace is allowed.** `workspace` is only checked for existence + `.halo/`; passing the caller's own path makes `getOrCreate` return the caller's own `SessionManager`, and everything else is unchanged. In effect relay is a `query_session` without the own-tree scoping — it reaches any **root** session in any workspace, including new ones. Not a hole: the tool is full-access only, and a full-access agent could already `shell_exec` its way into any session file.
36
36
  - **Reports come back from roots only.** `deliverRelayReport` fires for `parentId === null`; a sub-session's turn end goes to `tryReportToParent` instead. Dispatching to a `parent>child` id delivers the message but never a report — target roots when you want to hear back. Dispatching to yourself works and terminates (one self-report, `reply_to` cleared) but is pointless.
37
37
 
38
38
  ## Interim report (continue_task)
39
39
 
40
- A busy target that gets a follow-up `relay_send` answers it in a drained turn and, when its original task isn't done, calls `continue_task` — `drainQueue` then runs a resume turn in the **same** `runSession`. Every turn resets `output` / `finalOutput` (`runAgentTurn`'s per-turn reset — `get_session_output`, the session file, goal no-progress hashing and the report cap all depend on it), so the final report only carries the resume turn's wrap-up and the answer to the follow-up was lost.
40
+ A busy target that gets a follow-up `relay_send` answers it in a drained turn and, when its original task isn't done, calls `continue_task` — `drainQueue` then runs a resume turn in the **same** `runSession`. Every turn resets `output` / `finalOutput` (`runAgentTurn`'s per-turn reset — `get_session_output`, the session file and the report cap all depend on it), so the final report only carries the LAST turn's wrap-up and any earlier answer is lost. The same loss happens without a kick: two follow-ups in quick succession (the first answered with a plain `end_turn` while the second already sits in the queue), or a follow-up that lands while the **opening** turn is still running.
41
41
 
42
- Fix, at the kick point (not in `runSession`'s finally): right before `CONTINUE_TASK_KICK` is queued, `SessionManager.sendInterimReport` snapshots the answer turn's `finalOutput || output` and — **only if the batch just answered contains a relay message** (`text` contains `RELAY_CHANNEL_PREFIX`, no `sourceSessionId`) — calls `deliverRelayInterim(host, sessionId, body)`: read `reply_to` (no-op without one) → append + send into the caller
42
+ Fix, at **turn end** in `drainQueue` (after `runAgentTurn` returns, before the kick block — not in `runSession`'s finally): when *another turn follows* (`selfKick && !interruptRequested`, or `messageQueue` non-empty) *and the turn was an answer* (`selfKick` set, or `finalOutput !== ''` — a natural `end_turn`), `SessionManager.sendInterimReport` asks the pure `interimDoor(parentId, batch)` who the batch came from and — **only if the batch just answered contains a relay message** (`text` contains `RELAY_CHANNEL_PREFIX`, no `sourceSessionId`) — calls `deliverRelayInterim(host, sessionId, body)`: read `reply_to` (no-op without one) → append + send into the caller
43
43
 
44
44
  ```
45
- [Relay interim report · workspace <ws> · session <id>] The session answered your latest message (below) and is now resuming the task that message interrupted — its final [Relay report] follows when that is done.
45
+ [Relay interim report · workspace <ws> · session <id> · status: still running] This is an interim reply — the session is still working; its final [Relay report] follows when done. Do not treat this as the result.
46
46
 
47
47
  <answer, capped at limits.autoReportMax>
48
48
  ```
49
49
 
50
- No quiet gate (the session is by definition not done) and **`reply_to` is kept**, so `deliverRelayReport` still fires exactly once when the resumed task ends. Skipped when: no kick (plain answer → the final report already carries it), the interrupting message was local chat (typed into the department directly — nothing owed to the secretary), the answer turn errored, or the answer is empty. The same hook covers sub-agents (interim to the parent via `querySession`, see [session.md → continue_task](session.md#message-queue-and-drain)); goal workers are excluded.
50
+ The body is the turn's **whole** text (`session.output`, not `finalOutput`): the response that carries the `continue_task` tool_use has `stopReason === 'tool_use'`, so agent-loop marks its text `final: false` and it never reaches `finalOutput` — reading `finalOutput` would ship only whatever trailing line came after the tool call (or nothing). The tool description asks the model to write its reply *before* calling `continue_task`, but correctness doesn't depend on it.
51
+
52
+ **Opening turn** (`runSession`'s `hasFirst` turn, `sendOpeningInterim`): same condition (`messageQueue` non-empty at turn end, `finalOutput` set, no `turnError`), but the batch is the opening message itself (wrapped as a source-less entry): a sub-agent's opening message is its parent's dispatch, so it always owes the parent; a root goes through the same door as the drain path — the opening message must itself carry the relay prefix **and** a `reply_to` row must exist → `deliverRelayInterim`. A pending `reply_to` alone is not enough: a local user chatting into a root that still owes a relay report would otherwise have their answer forwarded to the caller.
53
+
54
+ **Deferred interim** (`sendDeferredInterim`, `runSession`'s finally, right before `tryReportToParent` / `deliverRelayReport`): a target that is itself waiting on sub-agents gets a follow-up `relay_send`, answers it, and the run ends with a child still running. Both final reports skip (subtree not quiet), and the child's later report wakes a new run whose first turn resets `output` — the answer would reach no one. So the run's last turn sends an interim when it answered the relay caller **and** a child was already running when that turn *started* (`RunLastTurn.waitingOnChildren` — door first, db lookup only past it) **and** the subtree is still busy at run end; `reply_to` stays, so the final report still fires once after the child. The dispatch turn itself (child started mid-turn) owes the final report, not an interim, and a turn that ends with the subtree quiet is reported as the final. Never overlaps the drain-path interim: a turn another turn follows is never the last.
55
+
56
+ No quiet gate (the session is by definition not done) and **`reply_to` is kept**, so `deliverRelayReport` still fires exactly once when the task ends. Skipped when: no next turn (plain answer with an empty queue → the final report already carries it; esc after `continue_task` → no kick, the run ends, same), the interrupting message was local chat (typed into the department directly — nothing owed to the secretary), the turn errored, or the text is empty. An aborted turn that leaves the queue non-empty produces no interim either (`turnError`); the next turn's end decides. The same hook covers sub-agents (interim to the parent via `querySession`, see [session.md → continue_task](session.md#message-queue-and-drain)).
51
57
 
52
58
  ## Soft vs hard interrupt
53
59
 
@@ -63,4 +69,4 @@ No quiet gate (the session is by definition not done) and **`reply_to` is kept**
63
69
 
64
70
  ## Test
65
71
 
66
- `packages/server/test/relay.test.ts` — stub `RelayTarget` + registry, 13 cases: `deliverRelayReport` no-op without `reply_to`, waits while a child is active, delivers exactly one append + one send with the header then clears, `ABORTED` prefix on `turnError`, ignores sub-sessions; `relay_send` creates + stamps `reply_to` + channel-prefixes the model text, rejects a nonexistent workspace; `relay_interrupt` busy → enqueue then abort + re-stamp, idle → no abort and never creates; `relay_list` roots only with title fallback, and defaults to the caller's own workspace. `deliverRelayInterim`: no-op without `reply_to`; delivers the interim header and keeps `reply_to`, the later final report still fires once. End-to-end through `drainQueue` (relay interim → final, no kick → no interim, local message → no interim) lives in `test/continue-task.test.ts`.
72
+ `packages/server/test/relay.test.ts` — stub `RelayTarget` + registry, 15 cases: `deliverRelayReport` no-op without `reply_to`, waits while a child is active, delivers exactly one append + one send with the header then clears, `ABORTED` prefix on `turnError` (and the account-error variant: "fix the model config" instead of the re-send hint), ignores sub-sessions; `relay_send` creates + stamps `reply_to` + channel-prefixes the model text, rejects a nonexistent workspace, returns the permanent error without a registry (CLI / TUI); `relay_interrupt` busy → enqueue then abort + re-stamp, idle → no abort and never creates; `relay_list` roots only with title fallback, and defaults to the caller's own workspace. `deliverRelayInterim`: no-op without `reply_to`; delivers the interim header and keeps `reply_to`, the later final report still fires once. End-to-end through `drainQueue` / `runSession` (relay interim → final, no kick → no interim, local message → no interim, opening-turn and deferred interims incl. the local-opening-message and quiet-subtree negatives) lives in `test/continue-task.test.ts`.