@turmind/halo 1.4.6 → 1.5.0-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.
- package/README.md +0 -1
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/160.c52d3d6cf7bc8286.js +1 -0
- package/admin-out/_next/static/chunks/220.e250bbbf65d24094.js +1 -0
- package/admin-out/_next/static/chunks/362.a0c58d3bf9b1300b.js +1 -0
- package/admin-out/_next/static/chunks/449.c4b881b9ebdc556e.js +1 -0
- package/admin-out/_next/static/chunks/613.fb09e55e0a61d3fe.js +1 -0
- package/admin-out/_next/static/chunks/73.05abce37d312e880.js +1 -0
- package/admin-out/_next/static/chunks/803-7128009ee0e1c10e.js +1 -0
- package/admin-out/_next/static/chunks/839.a2729fd33013d763.js +1 -0
- package/admin-out/_next/static/chunks/868.bbb91afd48c9eb58.js +1 -0
- package/admin-out/_next/static/chunks/892.a35118e09d8d8efd.js +1 -0
- package/admin-out/_next/static/chunks/989-7420ee650083df43.js +1 -0
- package/admin-out/_next/static/chunks/app/page-c33392db67853747.js +1 -0
- package/admin-out/_next/static/chunks/webpack-1a264efd965db7ab.js +1 -0
- package/admin-out/_next/static/css/7997e44c08e34b2b.css +3 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/agent.md +8 -7
- package/bundled-docs/design/architecture.md +9 -5
- package/bundled-docs/design/canvas-extensions.md +181 -0
- package/bundled-docs/design/cron.md +1 -1
- package/bundled-docs/design/feishu.md +3 -1
- package/bundled-docs/design/observability.md +2 -0
- package/bundled-docs/design/prompt-system.md +6 -10
- package/bundled-docs/design/relay.md +19 -15
- package/bundled-docs/design/session.md +15 -12
- package/bundled-docs/design/slack.md +2 -2
- package/bundled-docs/design/storage.md +4 -2
- package/bundled-docs/design/telegram.md +4 -2
- package/bundled-docs/design/wechat.md +7 -5
- package/bundled-docs/design/wecom.md +2 -2
- package/bundled-docs/design/ws.md +25 -18
- package/bundled-docs/dev/add-model-provider.md +21 -18
- package/bundled-docs/dev/api.md +25 -24
- package/bundled-docs/dev/env.md +2 -2
- package/bundled-docs/dev/tools.md +17 -82
- package/bundled-docs/guide/channels/wechat.md +1 -1
- package/bundled-docs/guide/cli.md +0 -1
- package/bundled-docs/guide/delegation-and-access.md +1 -2
- package/bundled-docs/guide/workspace.md +11 -0
- package/bundled-docs/requirements/chat.md +5 -4
- package/bundled-docs/requirements/command.md +1 -12
- package/bundled-docs/requirements/editor.md +9 -0
- package/bundled-docs/requirements/settings.md +9 -0
- package/dist/index.js +1898 -1037
- package/package.json +2 -2
- package/templates/agents/deep-executor/agent.yaml +0 -3
- package/templates/agents/default/AGENT.md +0 -17
- package/templates/agents/default/agent.yaml +0 -1
- package/templates/agents/executor/agent.yaml +1 -1
- package/templates/models/aws-bedrock-claude-invoke.yaml +31 -0
- package/templates/models/aws-bedrock-mantle.yaml +42 -7
- package/templates/models/aws-bedrock-openai.yaml +53 -17
- package/templates/prompts/root/DELEGATION.md +58 -0
- package/templates/skills/extension/SKILL.md +70 -0
- package/templates/skills/extension/templates/ext.sh +112 -0
- package/templates/skills/halo/SKILL.md +3 -0
- package/templates/skills/send-file/SKILL.md +1 -1
- package/admin-out/_next/static/chunks/220.1ed70c874686122f.js +0 -1
- package/admin-out/_next/static/chunks/23-092ac3901411582e.js +0 -1
- package/admin-out/_next/static/chunks/362.5970ef15ec3d7b15.js +0 -1
- package/admin-out/_next/static/chunks/449.a403f7ad18054e3f.js +0 -1
- package/admin-out/_next/static/chunks/613.f314fc12e05a566c.js +0 -1
- package/admin-out/_next/static/chunks/73.a4617a551a8e176c.js +0 -1
- package/admin-out/_next/static/chunks/767.16a0b33b6f5e7965.js +0 -1
- package/admin-out/_next/static/chunks/839.e381287756453ffe.js +0 -1
- package/admin-out/_next/static/chunks/868.cc7fd84c9f2fd711.js +0 -1
- package/admin-out/_next/static/chunks/892.92b2ed455683b49a.js +0 -1
- package/admin-out/_next/static/chunks/989-6a0ec35b8ae2cb39.js +0 -1
- package/admin-out/_next/static/chunks/app/page-bf9c0b21ae431c92.js +0 -1
- package/admin-out/_next/static/chunks/webpack-ca926228f8cba1ff.js +0 -1
- package/admin-out/_next/static/css/c0ea31cd787f7aa7.css +0 -3
- package/bundled-docs/design/goal-mode.md +0 -144
- /package/admin-out/_next/static/{qYSILjqBjfgTggO0T8E7T → Wn72NPclwda8BiGd0ChhY}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{qYSILjqBjfgTggO0T8E7T → Wn72NPclwda8BiGd0ChhY}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,181 @@
|
|
|
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 (`workspace-layout.tsx`), 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
|
+
`resolve(ext)` returns every way to open the file, best first:
|
|
85
|
+
|
|
86
|
+
1. `default` extensions, **newest `installedAt` first**
|
|
87
|
+
2. the built-in plugin, if any
|
|
88
|
+
3. `option` extensions (same ordering)
|
|
89
|
+
4. `text` (Open as Text) — always present, always last
|
|
90
|
+
|
|
91
|
+
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.
|
|
92
|
+
|
|
93
|
+
**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?")`.
|
|
94
|
+
|
|
95
|
+
### Iframe host (`extension-host.tsx` + `extension-host-logic.ts`)
|
|
96
|
+
|
|
97
|
+
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.
|
|
98
|
+
|
|
99
|
+
- 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).
|
|
100
|
+
- 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).
|
|
101
|
+
- 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.
|
|
102
|
+
- 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).
|
|
103
|
+
- 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.
|
|
104
|
+
|
|
105
|
+
### postMessage protocol v1
|
|
106
|
+
|
|
107
|
+
Types in `packages/core/src/protocol/extension-frames.ts` (`EXTENSION_PROTOCOL_VERSION = 1`), shared with hub extensions. All frames `{ haloExt: 1, type, … }`.
|
|
108
|
+
|
|
109
|
+
| Dir | `type` | Fields | Semantics |
|
|
110
|
+
|---|---|---|---|
|
|
111
|
+
| ext→host | `ready` | `protocol` | listener installed; host sends nothing before it. v1 host doesn't read `protocol` (field reserved for negotiation) |
|
|
112
|
+
| host→ext | `init` | `protocol`, `file{name,path,size,ext}`, `capabilities`, `theme` | once, right after `ready`; `capabilities` = what the host grants |
|
|
113
|
+
| host→ext | `load` | `buffer`, `mtime` | file bytes (transferred); resent when the file changes on disk and the doc isn't dirty → "replace current document" |
|
|
114
|
+
| ext→host | `dirty` | `dirty` | needs `save`; otherwise ignored + `console.warn` |
|
|
115
|
+
| host→ext | `save-request` | – | Save button pressed and doc dirty; ext answers `save` |
|
|
116
|
+
| ext→host | `save` | `buffer` | no `save` capability → `save-error{denied}`, no network; a save while one is in flight is dropped |
|
|
117
|
+
| host→ext | `saved` | `mtime` | PUT succeeded; ext clears dirty, `mtime` is the new baseline |
|
|
118
|
+
| host→ext | `save-error` | `reason: conflict\|denied\|io`, `message`, `mtime?` | ext stays dirty; UI is the host's |
|
|
119
|
+
| host→ext | `theme` | `theme` | on admin theme switch; may be ignored |
|
|
120
|
+
| ext→host | `error` | `message` | can't handle the file; host shows message + Open as Text / Download |
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
host extension
|
|
124
|
+
|-- iframe src=…/<token>/entry --->|
|
|
125
|
+
|<--------- ready{protocol:1} -----|
|
|
126
|
+
|-- init{file,capabilities,theme} >|
|
|
127
|
+
| fetch(viewUrl) + stat |
|
|
128
|
+
|-- load{buffer,mtime} ----------->| render
|
|
129
|
+
|<--------- dirty{true} -----------| markModified(path) → tab dot
|
|
130
|
+
| user clicks Save |
|
|
131
|
+
|-- save-request ----------------->|
|
|
132
|
+
|<--------- save{buffer} ----------|
|
|
133
|
+
| PUT /files/raw?expectMtime=… |
|
|
134
|
+
|-- saved{mtime} ----------------->| 200 (409 → conflict flow below)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Dirty, save, conflict
|
|
138
|
+
|
|
139
|
+
- `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.
|
|
140
|
+
- 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".
|
|
141
|
+
- `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`.
|
|
142
|
+
- **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.
|
|
143
|
+
|
|
144
|
+
### MRU, keep-alive, upgrade, uninstall
|
|
145
|
+
|
|
146
|
+
- `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.
|
|
147
|
+
- **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).
|
|
148
|
+
- **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.
|
|
149
|
+
- **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).
|
|
150
|
+
- **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.
|
|
151
|
+
|
|
152
|
+
## `extension` skill and halo-hub
|
|
153
|
+
|
|
154
|
+
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.
|
|
155
|
+
|
|
156
|
+
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).
|
|
157
|
+
|
|
158
|
+
## Extension capability boundary
|
|
159
|
+
|
|
160
|
+
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**.
|
|
161
|
+
|
|
162
|
+
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.
|
|
163
|
+
|
|
164
|
+
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.
|
|
165
|
+
|
|
166
|
+
## Deviations from the original design
|
|
167
|
+
|
|
168
|
+
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`.
|
|
169
|
+
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.
|
|
170
|
+
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.
|
|
171
|
+
4. **Conflict UI is chained `confirmAction`s**, not a dedicated overwrite / discard / cancel dialog (see Dirty, save, conflict).
|
|
172
|
+
5. **Uninstalling under a dirty tab** keeps the iframe alive with a banner — no confirm. A non-dirty tab shows the fallback page.
|
|
173
|
+
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`.
|
|
174
|
+
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.
|
|
175
|
+
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.
|
|
176
|
+
|
|
177
|
+
## Known limitations
|
|
178
|
+
|
|
179
|
+
- `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.
|
|
180
|
+
- `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.
|
|
181
|
+
- 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.
|
|
@@ -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)
|
|
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.
|
|
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,50 @@ Halo runs many workspaces on one server — one per team / project / credential
|
|
|
8
8
|
|
|
9
9
|
## Mechanism
|
|
10
10
|
|
|
11
|
-
Relay
|
|
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
|
-
| |
|
|
14
|
-
|
|
15
|
-
| Back-pointer column | `agent_sessions.
|
|
16
|
-
| Delivery hook | `
|
|
17
|
-
| Quiet gate | root + no active children + empty queue
|
|
18
|
-
| Tool set | `
|
|
19
|
-
| Reach |
|
|
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) → 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
|
|
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 — 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
|
|
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
|
|
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
|
|
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` checks 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>
|
|
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
|
-
|
|
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 there is no batch to read the door from — the opening message *is* the dispatch — so the owed party is whoever the session reports to at the end: a root with a `reply_to` row → `deliverRelayInterim`; a sub-agent → its parent.
|
|
53
|
+
|
|
54
|
+
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
55
|
|
|
52
56
|
## Soft vs hard interrupt
|
|
53
57
|
|
|
@@ -98,7 +98,7 @@ interface AgentSession {
|
|
|
98
98
|
agent: ModelRuntime
|
|
99
99
|
description: string
|
|
100
100
|
output: string // all assistant text of the current turn (reset per turn)
|
|
101
|
-
finalOutput: string // wrap-up text only (stopReason !== 'tool_use') — feeds auto-report /
|
|
101
|
+
finalOutput: string // wrap-up text only (stopReason !== 'tool_use') — feeds auto-report / relay report
|
|
102
102
|
lastActivityAt: string | null // ISO of the turn's latest text / tool_call / tool_result event; null at turn start — surfaced by get_session_output
|
|
103
103
|
turnError: string | null // set when the turn died on an unrecoverable error — prefixes the report with an ABORTED marker
|
|
104
104
|
promise: Promise<string> | null // non-null = running
|
|
@@ -147,11 +147,11 @@ Hierarchical encoding: `root_id>child_segment>grandchild_segment`.
|
|
|
147
147
|
|
|
148
148
|
Both true → sets `stoppedAt` + emits `agent_done` + `querySession`s the result back to the parent. The parent's `querySession` clears its own `stoppedAt`, handles the report, and may trigger its own `tryReportToParent` — bubbling up.
|
|
149
149
|
|
|
150
|
-
**Abnormal-termination marker**: when the reported turn was killed by an unrecoverable error (retry budget exhausted / account-level failure), the session's `turnError` field holds the error text and the auto-report is prefixed with an explicit `[SUB-AGENT ABORTED: … Error: <text> … Re-dispatch with query_session(…) to resume.]` block. Without it the parent LLM consumed mid-turn fragments (or a literal "(no output)") as completed reports — the cron-era "sub-agent reported without finishing" incident, root-caused to a Bedrock h2 hang (`TimeoutError: http2 request did not get a response`, now also in the transient-transport retry list). Prefix, don't suppress: skipping the report would leave the parent waiting forever, the partial trace has diagnostic value, and `stoppedAt` is stamped as usual so the child never shows as falsely running. The marker is prepended *before* the truncation cap so it can never be sliced off.
|
|
150
|
+
**Abnormal-termination marker**: when the reported turn was killed by an unrecoverable error (retry budget exhausted / account-level failure), the session's `turnError` field holds the error text and the auto-report is prefixed with an explicit `[SUB-AGENT ABORTED: … Error: <text> … Re-dispatch with query_session(…) to resume.]` block. Without it the parent LLM consumed mid-turn fragments (or a literal "(no output)") as completed reports — the cron-era "sub-agent reported without finishing" incident, root-caused to a Bedrock h2 hang (`TimeoutError: http2 request did not get a response`, now also in the transient-transport retry list). Prefix, don't suppress: skipping the report would leave the parent waiting forever, the partial trace has diagnostic value, and `stoppedAt` is stamped as usual so the child never shows as falsely running. The marker is prepended *before* the truncation cap so it can never be sliced off.
|
|
151
151
|
|
|
152
|
-
The full finally-chain order is `tryReportToParent → deliverGoalRound → deliverRelayReport → releaseSession
|
|
152
|
+
The full finally-chain order is `tryReportToParent → deliverGoalRound → deliverRelayReport → releaseSession`. `deliverGoalRound` is a retained legacy hook (goal mode's round delivery): it keys off the row's `goal_session_id` and works as before for any existing goal binding. After the parent-report check, a root whose row carries a `reply_to` back-pointer (it was dispatched from another workspace via `relay_send`) gets its wrap-up appended + sent into the caller session in that workspace (same subtree-quiet gate, `reply_to` cleared before sending — see [relay.md](relay.md)), then the session is released. Each hook decides on its own db column (`parent_id` / `goal_session_id` / `reply_to`), so the three never fire for the same session.
|
|
153
153
|
|
|
154
|
-
- **Sibling-status injection for root** (`siblingStatusSuffix`): `tryReportToParent` early-returns for root (`parentId === null`) since there's no parent to bubble up to — so root never learns whether its *other* children are still running, and the root LLM could wrap up early after consuming just one child's report. When root consumes a child report (`querySession` idle branch + `drainQueue`), a sibling-status line is appended to the message fed to the LLM. "All sub-agents done" requires **both** no sibling running in the DB (`parentId = root AND stoppedAt IS NULL`) **and** an empty in-memory `messageQueue` — a child can be stopped while its report is still queued, so the DB check alone would falsely declare completion. The reporting child needs no identity exclusion: it stamped `stoppedAt` before the report was delivered, so `stoppedAt IS NULL` already excludes it (unless re-dispatched a new task, which clears `stoppedAt` — then it correctly counts as running). The line carries per-child `created` + `last active` timestamps so a capable model can tell a freshly dispatched sibling from an original-batch leftover. (Its `[System @ <iso>]` header used to be the *only* wall-clock signal the model ever saw; since 1.1.6 every turn carries an arrival stamp — see [Message queue and drain](#message-queue-and-drain).) Mid-tier parents are excluded by design — their `tryReportToParent` bubble-up already gates them on a fully-drained subtree.
|
|
154
|
+
- **Sibling-status injection for root** (`siblingStatusSuffix`): `tryReportToParent` early-returns for root (`parentId === null`) since there's no parent to bubble up to — so root never learns whether its *other* children are still running, and the root LLM could wrap up early after consuming just one child's report. When root consumes a child report (`querySession` idle branch + `drainQueue`), a sibling-status line is appended to the message fed to the LLM. "All sub-agents done" requires **both** no sibling running in the DB (`parentId = root AND stoppedAt IS NULL`) **and** an empty in-memory `messageQueue` — a child can be stopped while its report is still queued, so the DB check alone would falsely declare completion. The reporting child needs no identity exclusion: it stamped `stoppedAt` before the report was delivered, so `stoppedAt IS NULL` already excludes it (unless re-dispatched a new task, which clears `stoppedAt` — then it correctly counts as running). The line carries per-child `created` + `last active` timestamps so a capable model can tell a freshly dispatched sibling from an original-batch leftover. (Its `[System @ <iso>]` header used to be the *only* wall-clock signal the model ever saw; since 1.1.6 every turn carries an arrival stamp — see [Message queue and drain](#message-queue-and-drain).) The same rows are also emitted to the admin as a `system` event (root only) so a reviewer can see what root was told; that UI copy has a clock-less `[System]` header (the message carries its own `timestamp`) and relative ages (`started 46m ago, last active 2m ago`, via `formatAge`) instead of the ISO stamps the model gets. Mid-tier parents are excluded by design — their `tryReportToParent` bubble-up already gates them on a fully-drained subtree.
|
|
155
155
|
|
|
156
156
|
- **interruptSession**: fire-and-forget abort of the in-flight turn — it sets `interruptRequested` so `runAgentTurn`'s unwind repairs (not errors), then aborts. It does **not** await or re-run: once the aborted turn unwinds, `runSession`'s finally sees the non-empty queue and `drainQueue` folds the queued message into one merged follow-up turn. The `interrupt_session` tool reaches this via `querySession(..., interrupt=true)` (enqueue + abort), so there is no separate re-run path or `skipRelease` bookkeeping.
|
|
157
157
|
|
|
@@ -161,17 +161,17 @@ A sub-session whose process was killed mid-run never got its `stoppedAt` written
|
|
|
161
161
|
|
|
162
162
|
**Workspace-level gate (`.halo/runtime.lock`)**: `server.lock` ownership alone is not sufficient — two servers with different `HALO_HOME` (e.g. prod + dev) each hold their own `server.lock` yet can point at the *same* workspace directory, and one server's boot reconcile would batch-stop the other's actually-live sub-sessions (the incident this gate exists for). So the reconcile additionally requires `claimWorkspaceRuntime(workspaceRoot)` (`agents/workspace-runtime-lock.ts`) — a pid marker at `<workspace>/.halo/runtime.lock` with a liveness probe. Claim fails → skip reconcile and log a warning: **prefer missing a crash-orphan cleanup over stopping another process's live sessions**. `reconcileOrphansOnBoot: true` therefore means "reconcile if the workspace claim succeeds," not "always." Lock protocol details in [storage.md](storage.md#workspace-runtime-lock); known residual: when two servers both actively use one workspace long-term, the non-owner never reconciles — its own crash orphans stay un-cleaned until the owner restarts and takes over. The run-ledger eager boot loop below deals with the *un-opened* case for the same residual: a workspace whose runtime another live process owns is skipped before a SessionManager is even built, so the rows wait for a later boot instead of freezing behind a cached non-owner instance.
|
|
163
163
|
|
|
164
|
-
The constructor chain is now `reconcile orphans → sweepActiveGoals → sweepInterruptedRuns` — all three gated on the same `reconcileOrphansOnBoot` + `.halo/runtime.lock` claim, so a workspace that fails the claim runs none of them.
|
|
164
|
+
The constructor chain is now `reconcile orphans → sweepActiveGoals (retained legacy) → sweepInterruptedRuns` — all three gated on the same `reconcileOrphansOnBoot` + `.halo/runtime.lock` claim, so a workspace that fails the claim runs none of them.
|
|
165
165
|
|
|
166
166
|
### Run ledger — restart nudge for interrupted roots (`~/.halo/global/runs.db`)
|
|
167
167
|
|
|
168
|
-
The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the **root** the server restarted — asked "done yet?" it still answers "waiting on reports" forever.
|
|
168
|
+
The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the **root** the server restarted — asked "done yet?" it still answers "waiting on reports" forever. Plain roots had no restart nudge, because "which session is running right now" only ever lived in memory.
|
|
169
169
|
|
|
170
170
|
**Ledger** (`db/runs-db.ts`): a global `~/.halo/global/runs.db` (same singleton pattern as `evo.db` / `cron.db` — `setRunsDb`/`getRunsDb`, WAL), one table `running_sessions(workspace TEXT, session_id TEXT, started_at INTEGER, PK(workspace, session_id))`. `runSession` inserts the id on entry and deletes it in its `finally`, so the steady state is an **empty table** — whatever is still there at boot is exactly the set of runs the previous process died in the middle of; no pid, no liveness probe needed. Only the long-lived server writes: `SessionManager.ledgerEnabled = opts.reconcileOrphansOnBoot === true`, the same flag that gates the orphan reconcile — cli / cron / TUI / evo-wrapper runs end with their terminal and never enter the table. Writes go through a private `ledgerWrite` wrapper that try/catches and only `console.warn('[RunLedger] ledger write failed …')` on failure: a sqlite hiccup (disk full, EIO) must not drop a message or strand a session at `promise = null` without a `complete` — worst case is one stale/missing row, i.e. one spurious or missed nudge at next boot.
|
|
171
171
|
|
|
172
|
-
**Sweep** (`sweepInterruptedRuns`, `agents/run-ledger.ts`): runs from the SessionManager constructor right after `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 (text starts `[System] The server restarted at <ISO> while you were mid-turn…`). The wording is 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`; sub-sessions are 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. Skipped per root: row missing or `archivedAt` set;
|
|
172
|
+
**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 (text starts `[System] The server restarted at <ISO> while you were mid-turn…`). The wording is 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`; sub-sessions are 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. 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).
|
|
173
173
|
|
|
174
|
-
**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 → `sweepActiveGoals` → `sweepInterruptedRuns`). An interrupted root with nobody around is exactly the one that must nudge itself without a human opening the tab first.
|
|
174
|
+
**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.
|
|
175
175
|
|
|
176
176
|
`runs.db`/`-wal`/`-shm` join the sandbox `hidden_files` default alongside `evo.db` / `cron.db` (see [storage.md](storage.md)). Known gap carried from the design doc: `<ISO>` in the nudge is sweep time, not the actual interruption time — for a workspace that sat locked by another server before this boot could claim it, that's a real but accepted drift.
|
|
177
177
|
|
|
@@ -187,7 +187,7 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
|
|
|
187
187
|
|
|
188
188
|
**Arrival stamp.** Both paths run the turn through `runAgentTurn`, which prepends `[<ISO-8601 UTC>] ` (e.g. `[2026-09-12T15:17:44.153Z] `) to the first text block of the model-facing input — an image-only input gets a stamp-only text block unshifted. The model has no clock; without this it couldn't tell a reply that came two days later from one that came instantly, or how long a sub-agent report took to land. Stamped **once, before the retry loop**, so every attempt (and the [multimodal 4xx degrade](#multimodal-4xx-degrade) rebuild) reuses the identical input. Agent reports therefore read `[<iso>] (from: session X)\n…` — stamp outermost, no special-casing. Only `agent.messages` changes: the UI `type:'user'` event keeps the raw text (see [Turn matching](#exchange-deletion-soft-ui--hard-raw) for the strip on compare), and the self-compact instruction / `[Conversation Summary]` injection / `tool_result` messages are never stamped.
|
|
189
189
|
|
|
190
|
-
**Final-text flag.** The agent loop marks each text block `final` when the model call ended for a reason other than `tool_use` (i.e. it is the turn's wrap-up, not the filler emitted before a tool call), and `runAgentTurn`'s `case 'text'` forwards it on the `stream` event as `AgentSessionEvent.final`. Block-send consumers gate on it: the channel responders (wechat / telegram / slack / feishu) append only `final` text, and `halo cli` prints the last root turn's `final` text as its stdout (fallback: that turn's full text). `finalOutput` (used by `tryReportToParent`
|
|
190
|
+
**Final-text flag.** The agent loop marks each text block `final` when the model call ended for a reason other than `tool_use` (i.e. it is the turn's wrap-up, not the filler emitted before a tool call), and `runAgentTurn`'s `case 'text'` forwards it on the `stream` event as `AgentSessionEvent.final`. Block-send consumers gate on it: the channel responders (wechat / telegram / slack / feishu) append only `final` text, and `halo cli` prints the last root turn's `final` text as its stdout (fallback: that turn's full text). `finalOutput` (used by `tryReportToParent`) is the same accumulation on the server side. Streaming providers additionally yield `text_delta` / `thinking_delta` events during the model call (forwarded as `stream_delta` / `thinking_delta`, UI-only); they never touch `output` / `finalOutput` — those only ever see the whole `text` event, which follows every completed call exactly as before. The one place the loop itself keeps the text deltas is the interrupted-stream landing (see [Partial streamed text lands](#conversation-repair)): a call cancelled mid-stream pushes what it had streamed as a marked assistant turn so the model's history matches what the UI showed.
|
|
191
191
|
|
|
192
192
|
**drainQueue** folds the **whole queue** into ONE merged follow-up turn per round, re-checking after each round (a fresh interrupt or a sibling's report can land mid-drain):
|
|
193
193
|
- The batch is `splice(0)`'d; agent entries keep a `(from: session X)` prefix, user entries fold raw, and all entries' `images` are merged in.
|
|
@@ -206,8 +206,9 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
|
|
|
206
206
|
**`continue_task` — resume after interrupt.** All three interrupt paths share a say/do gap: the interrupted turn unwinds, `drainQueue` runs the new message as a fresh turn, the model answers it and `end_turn`s — it *says* "continuing" but the interrupted work never resumes (worse for sub-agents, whose answer is then auto-reported to the parent as if it were the finished result). The built-in `continue_task` tool (every agent, no params — the one truly unconditional tool: `activate_skill` is gated on `skills`, session tools on `team`; wired in `session-agent-builder`) closes it with a flag pair on `AgentSession`:
|
|
207
207
|
- **`resumedAfterInterrupt`** is snapshotted from `interruptRequested` at the top of every drained batch (before the per-batch reset) and set `false` for the opening turn. `requestSelfKick` — the tool backend — returns `not_interrupted` and sets nothing unless it is `true`, so a normal turn can't arm the flag: **kicks ≤ interrupts**, no perpetual motion.
|
|
208
208
|
- **`selfKick`** lives exactly one turn: reset at the top of every drained batch. If that reset drops a `true` (the model armed it, then got interrupted *again* before the kick fired), a one-line `[System] … flag … was reset by this interrupt … call continue_task again` note is appended to that batch's merged input — the model can't see the flag, so it's told.
|
|
209
|
-
- **Kick position**: inside `drainQueue`'s while-loop, after `runAgentTurn` and **before** the `batchBoundary` check. When `selfKick` is set, the queue is empty **and `interruptRequested` is false**, it pushes a synthetic `[System] You called continue_task: … resume it now …` user turn (`CONTINUE_TASK_KICK`) and traces it as a `type:'user'` event with `report: true` (not a `system` event — the kick is a raw user turn, so it needs a UI user row or `deleteExchange`'s UI span and `deleteRawTurn`'s raw span diverge; traced at enqueue like every queued message, routed into a sub-agent's log via `taskId`); the existing boundary `complete` then fires naturally so block channels flush the answer as its own message and the kick turn gets its own bubble. The next iteration sees `interruptRequested === false` → `resumedAfterInterrupt = false`, so the kick turn cannot re-arm. If something else landed mid-turn the kick is skipped — the next iteration's reset + note handles it (the newest message might be "stop"; the model decides with that in view, not the loop). The `!interruptRequested` gate covers the other abort path: **esc / `/interrupt` never kicks** — `interruptSession` sets the flag and aborts *without* enqueuing, so the queue check alone would have let an esc'd turn resume itself; esc means "stop what you're doing". Whenever the loop is about to exit on an empty queue the flag is cleared so it never dangles. It is deliberately **not** in `runSession`'s `finally`: `tryReportToParent` / `
|
|
210
|
-
- **Interim report
|
|
209
|
+
- **Kick position**: inside `drainQueue`'s while-loop, after `runAgentTurn` and **before** the `batchBoundary` check. When `selfKick` is set, the queue is empty **and `interruptRequested` is false**, it pushes a synthetic `[System] You called continue_task: … resume it now …` user turn (`CONTINUE_TASK_KICK`) and traces it as a `type:'user'` event with `report: true` (not a `system` event — the kick is a raw user turn, so it needs a UI user row or `deleteExchange`'s UI span and `deleteRawTurn`'s raw span diverge; traced at enqueue like every queued message, routed into a sub-agent's log via `taskId`); the existing boundary `complete` then fires naturally so block channels flush the answer as its own message and the kick turn gets its own bubble. The next iteration sees `interruptRequested === false` → `resumedAfterInterrupt = false`, so the kick turn cannot re-arm. If something else landed mid-turn the kick is skipped — the next iteration's reset + note handles it (the newest message might be "stop"; the model decides with that in view, not the loop). The `!interruptRequested` gate covers the other abort path: **esc / `/interrupt` never kicks** — `interruptSession` sets the flag and aborts *without* enqueuing, so the queue check alone would have let an esc'd turn resume itself; esc means "stop what you're doing". Whenever the loop is about to exit on an empty queue the flag is cleared so it never dangles. It is deliberately **not** in `runSession`'s `finally`: `tryReportToParent` / `deliverRelayReport` fire there and must see the *finished* work, so the kick turn has to complete first.
|
|
210
|
+
- **Interim report at turn end** (`sendInterimReport`, called in `drainQueue` right after `runAgentTurn` returns, before the kick block): the next turn resets `output` / `finalOutput`, and every end-of-run report (`tryReportToParent` / `deliverRelayReport`) reads only the last turn — so a turn that *another turn follows* would never reach whoever asked. Trigger: `(selfKick && !interruptRequested) || messageQueue.length > 0` (a kick is coming, or a message is already queued — the kick itself is skipped then, the interim must not be) **and** the turn was an answer (`selfKick` set, or `finalOutput !== ''` from a natural `end_turn`). An esc after `continue_task` fails the first half → no kick → the run ends → the final report carries the turn, no interim. The per-turn reset stays (get_session_output, the session file and the report cap depend on it); instead the turn's text is snapshotted here and forwarded **only when the batch came from the party this session owes a report**: a sub-agent whose batch has a message with `sourceSessionId === parentId` → `querySession(parent, self, "[Interim report · status: still running] … Not the final result.\n\n<answer>")`, **without** stamping `stoppedAt` or emitting `agent_done` (the parent's sibling-status still lists the child as running → "Do NOT wrap up yet"; the real auto-report follows at the end); a root whose batch has a relay-prefixed message → `deliverRelayInterim` (keeps `reply_to`, see [relay.md → Interim report](relay.md#interim-report-continue_task)). Both paths share `deliverInterim(session, 'parent' | 'relay')`. The body is the **whole** turn's `output`, not `finalOutput`: the response carrying the `continue_task` tool_use is `stopReason: tool_use`, so its text is `final: false` and never lands in `finalOutput` — an answer written before the call would otherwise be dropped. Nothing for local user chat (the channel already streamed the answer and a `batchBoundary` complete flushes it), a sibling/other-session message, or an errored turn. Capped at `limits.autoReportMax`.
|
|
211
|
+
- **Opening-turn interim** (`sendOpeningInterim`, in `runSession`'s `runFn` right after the `hasFirst` turn): the same loss happens when a follow-up is queued while the opening turn runs — the drain's first turn resets the opening wrap-up. Fires when `messageQueue` is non-empty, `finalOutput !== ''` and no `turnError`. No batch to read the door from (the opening message *is* the dispatch), so the owed party is whoever gets the end-of-run report: `parentId !== null` → parent path; root with a `reply_to` row → relay path; otherwise nothing.
|
|
211
212
|
- **Stop / delete / archive never resurrect**: `stopSession` and `stopUserSession` clear `selfKick` next to `interruptRequested`; `deleteSession` (root + children) and `archiveSession` clear it right after nulling `abortController` and *before* awaiting `session.promise` (the aborted turn's drain still runs inside that promise and would otherwise fire a real kick turn); and `requestSelfKick` returns `no_turn` when `session.abortController === null` — Stop nulls it *before* the abort lands, so a tool callback that completes after a Stop can't re-arm the flag. `enqueueUserMessage` (admin WS) mirrors `sendUserMessage` and does **not** set `interruptRequested` while compacting — there is no live turn, and `endCompact`'s drain would otherwise snapshot it into `resumedAfterInterrupt` for a turn that interrupted nothing.
|
|
212
213
|
|
|
213
214
|
**Stop preserves, archive discards.** Both `stopSession` and `stopUserSession` (the user Stop button) fold the **whole** `messageQueue` into `agent.messages` as a user turn (user entries raw, agent entries with their `(from: session X)` prefix) **before** aborting, then `repairConversationMessages` — a stop **parks** the work, it never drops a queued message (mirrors interrupt, which also never loses one). `archiveSession`, by contrast, clears the queue without folding — archiving a subtree is a deliberate discard.
|
|
@@ -272,7 +273,7 @@ Table `agent_sessions` holds metadata only (no runtime state):
|
|
|
272
273
|
| created_at / updated_at | INTEGER | Epoch ms |
|
|
273
274
|
| stopped_at | INTEGER | null = active |
|
|
274
275
|
| archived_at | INTEGER | null = not archived |
|
|
275
|
-
| goal / goal_session_id | TEXT |
|
|
276
|
+
| goal / goal_session_id | TEXT | Retained legacy columns (goal mode binding JSON / back-pointer); existing bindings are unchanged and still honored |
|
|
276
277
|
| reply_to | TEXT | Relay: JSON `{ workspace, sessionId }` of the caller in another workspace, set by `relay_send`, cleared when the report is delivered — see [relay.md](relay.md) |
|
|
277
278
|
| title / exchange_count / context_tokens / total_output_tokens | TEXT / INTEGER | List-visible metadata mirrored from the session file header; null = row predates the columns |
|
|
278
279
|
|
|
@@ -301,6 +302,8 @@ A `toolUseId`-based algorithm that repairs message arrays damaged by abort / int
|
|
|
301
302
|
|
|
302
303
|
**Partial batches land, only the cut tool is synthesized** (`agent-loop.ts`, the tool loop's `try/finally`): in a parallel `tool_use` batch the loop pushes the accumulated `tool_result` user message in a `finally`, so both interrupt exits — the loop's own cancel check (soft interrupt: the consumer aborts after a `tool_result`, the next tool sees the signal) and the consumer breaking its `for await` (hard interrupt: finishes the generator at the `yield` via `.return()`) — still land the results that finished. Before this, either exit skipped the push, so every completed result was lost and repair marked the **whole** batch `[interrupted]`; the model then re-ran work that had already happened, which for side-effecting calls (commit, append, send) is a double execution — and `continue_task`'s "re-issue interrupted calls" instruction made that hazard live. The tool the abort landed on is dropped on purpose (its result is a killed shell's partial output), so repair pairs exactly that id — and any never-started ones — with the do-not-retry marker.
|
|
303
304
|
|
|
305
|
+
**Partial streamed text lands** (`agent-loop.ts`, `run()`'s model-call `try/finally`): every streaming provider re-establishes the non-streaming contract on a cancel — it throws `AbortError` and returns no partial result — so the text already streamed existed only as `text_delta` events. The UI log persisted it (`appendStream`), the user read it, but `agent.messages` had no assistant turn: the next user message coalesced into the dangling user turn and, asked "what did you just write?", the model answered it had no record. The loop now accumulates `text_delta`s per model call and, when the call did **not** resolve and the **caller's** `cancelSignal` is aborted, pushes them as one assistant text block suffixed with `INTERRUPTED_REPLY_MARKER` (`[reply interrupted by the user here — the text above is what was shown before the cut]`). Same two exits as the tool batch above: the provider's rethrown `AbortError`, and the consumer breaking its `for await` at a delta `yield` (`runAgentTurn`'s `if (signal.aborted) break` → `.return()` → `finally`). Deliberately narrow: **text only** — a partial thinking block has no signature and Anthropic / Bedrock reject unsigned thinking in history, and a partial `tool_use` never reaches this point (the providers throw rather than hand back a half-built input that would be JSON-parsed / paired with a synthetic `tool_result` and make the model believe it made the call). **Gated on the caller's signal, not the idle-timeout controller**: a `MODEL_TIMEOUT_ERROR` is retried by `runAgentTurn` and the retry re-streams from scratch, so landing there would duplicate the text. No new event is emitted (the UI already has the partial) and `output` / `finalOutput` are untouched. Repair is a no-op on the landed block (Phase 1 only strips *empty* text blocks); the next `run()` sees a trailing assistant turn and pushes the user message fresh instead of coalescing. An abort before the first text delta still leaves the dangling user turn, exactly as before.
|
|
306
|
+
|
|
304
307
|
**Provider-side consumers**: the OpenAI-style agents (DeepSeek / Kimi / Doubao / Hunyuan / Zhipu / Mantle / generic OpenAI) convert a user turn's `tool_result` blocks into tool-role messages — any non-`tool_result` content coalesced into the same turn (e.g. the synthesized result landing alongside real user text, or a stop-fold) is emitted as a following user message rather than silently dropped.
|
|
305
308
|
|
|
306
309
|
### UI-side interrupt marker (`markPendingToolCallsInterrupted`)
|
|
@@ -103,7 +103,7 @@ Unlike Telegram (grammy polling) or WeChat (HTTP long-poll), Socket Mode elimina
|
|
|
103
103
|
7. Strip the `<@botUserId>` mention prefix from the text
|
|
104
104
|
8. (DMs only) Check if text is a slash command (starts with `/` or `!`); if so, dispatch via `channels/shared/commands.ts` and return early
|
|
105
105
|
9. Download and ingest any attached files (save images + files to workspace media store; images base64'd for vision)
|
|
106
|
-
10. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)): resolve or create the session for this {channel, thread},
|
|
106
|
+
10. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)): resolve or create the session for this {channel, thread}, send a busy hint if needed, refresh the reply route `{channelId, replyTs}`, attach the `SlackResponder` listener once (torn down on `stopAccount` via `bridge.closeAll()`), then queue the user message with SessionManager
|
|
107
107
|
|
|
108
108
|
### Outbound (SlackResponder)
|
|
109
109
|
|
|
@@ -203,7 +203,7 @@ Text, images (base64 attached to agent input for vision), files. All files are d
|
|
|
203
203
|
|
|
204
204
|
The agent runtime produces `MEDIA: <path>` markers. SlackResponder:
|
|
205
205
|
- Intercepts the markers and calls `uploadFile()` for each one
|
|
206
|
-
- Blocks sandbox violation (files must be under workspace or /tmp)
|
|
206
|
+
- Blocks sandbox violation for non-`full` accounts (files must be under workspace or /tmp — `isMediaPathAllowed(path, workspace, accessLevel)`; `full` accounts may send any readable path). A block throws inside the same try as the upload, so it surfaces as the `handler.upload_failed` text below
|
|
207
207
|
- Falls back to error text if upload fails
|
|
208
208
|
|
|
209
209
|
Outbound media uses Slack's v2 upload flow (getUploadURLExternal → signed PUT → completeUploadExternal).
|