@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
|
@@ -50,7 +50,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
50
50
|
├── sessions/<agentId>/ # Session files (one per regular session)
|
|
51
51
|
├── memory/ # Project memory (dated entries)
|
|
52
52
|
├── canvas/self.html # Agent's visual face — platform-owned, force-copied on every open (design/express-self.md)
|
|
53
|
-
├── goal/<goalId>/ #
|
|
53
|
+
├── goal/<goalId>/ # Retained legacy goal-mode artifacts (GOAL_SPEC.md + decision-<n>.md); existing goals unchanged
|
|
54
54
|
├── logs/
|
|
55
55
|
├── evo/ # Self-evolution per-workspace artifacts
|
|
56
56
|
│ ├── runs/<id>/ # per-evaluation: source-snapshot.json, tool-flow.md,
|
|
@@ -209,6 +209,8 @@ type ContentBlockEntry =
|
|
|
209
209
|
|
|
210
210
|
`turnId` uniquely identifies each LLM API call. Content blocks and the corresponding usage message in the same turn share the same turnId.
|
|
211
211
|
|
|
212
|
+
The `usage` event rotates `turnId` on both root and sub-session turn state without flushing the assistant message: the agent loop yields `tool_call`s before `usage` and runs the tools after, so at usage time every tool call of that model call is still pending — a flush there would split thinking/text from the tool_calls into two assistant messages (the pre-fix sub-session behavior). One assistant message therefore spans all model calls of a turn, each call's blocks tagged with its own turnId; it reaches disk at `complete` / `agent_done`, the next `user` or `system` event, or the interrupt flush. The root file additionally carries the in-flight turn as a temporary assistant message on every save (`createSaveSnapshot`); a sub-session file does not (`persistSubSession` id-merges settled rows only), so while a sub-agent runs its file has the tool_call / tool_result / usage rows but no assistant message yet — the live view comes from WS / `getSessionView`, not the file.
|
|
213
|
+
|
|
212
214
|
### Render rules
|
|
213
215
|
|
|
214
216
|
| Mode | Visible | Hidden |
|
|
@@ -576,7 +578,7 @@ The databases hold session metadata indexes and workspace-scoped preferences (e.
|
|
|
576
578
|
| created_at / updated_at | INTEGER | Unix ms |
|
|
577
579
|
| stopped_at | INTEGER | Stopped timestamp (null = active) |
|
|
578
580
|
| archived_at | INTEGER | Archived timestamp (null = not archived) |
|
|
579
|
-
| goal / goal_session_id | TEXT |
|
|
581
|
+
| goal / goal_session_id | TEXT | Retained legacy columns (goal mode binding JSON / back-pointer); existing bindings are unchanged and still honored |
|
|
580
582
|
| reply_to | TEXT | Relay — JSON `{ workspace, sessionId }` of the caller session in another workspace; set by `relay_send`, cleared on report delivery. Added in `HALO_MIGRATIONS` slot (`db/index.ts`). |
|
|
581
583
|
| title | TEXT | Mirror of the session file's `title` |
|
|
582
584
|
| exchange_count | INTEGER | Mirror: main user turns over the session's lifetime (kept + archived) |
|
|
@@ -22,7 +22,7 @@ All four IM channels (telegram / wechat / slack / feishu) route inbound messages
|
|
|
22
22
|
Three pieces:
|
|
23
23
|
|
|
24
24
|
- **`InboundBridge<Route>`** — per-account bookkeeping for session listeners + their reply routes (`setRoute` / `getRoute` / `ensureListener` / `dropListener` / `closeAll`). The **reply route** — where to send, plus any per-message credential like wechat's context token — is a per-session value refreshed on **every** inbound message, and responders read it lazily at send time via `bridge.getRoute(sessionId)`. A listener registered once therefore can't lock onto a stale destination. Route entries are dropped together with their listener, so the map can't outgrow the listener set (the old per-channel context-token map never got cleaned up) — but **only once the responder has drained**: `close()` may return a promise (slack / feishu / wechat serialize their per-chunk sends and hand one back; telegram still fires each chunk and returns `void`), and the bridge defers the route deletion until it settles. Dropping the route synchronously stranded the tail of a split reply with nowhere to send (audit A-L3). `ensureListener` is idempotent per session; the account runner owns the bridge so `stopAccount` tears everything down with `closeAll()`, which walks the same per-listener teardown — deliberately no blanket `routes.clear()`, since that would delete the routes in-flight sends are about to read.
|
|
25
|
-
- **`deliverInbound`** — the tail itself: `rememberLastActiveChat` → access-level projection → resolve-or-create the channel session →
|
|
25
|
+
- **`deliverInbound`** — the tail itself: `rememberLastActiveChat` → access-level projection → resolve-or-create the channel session → busy/compacting hint (hint only; delivery continues regardless) → `setRoute` → `ensureListener` → `appendUserMessage(uiText)` + `sendUserMessage("[channel: X | user: Y | thread: Z]\n" + agentText)`. Returns the session id.
|
|
26
26
|
- **`dispatchChannelCommand`** — wraps the shared `dispatchCommand` and, when the result says `startedTurn`, wires route + listener exactly like the plain-message path, so no channel can forget it again. A call site that can't build a route logs instead of attaching a responder that could never send.
|
|
27
27
|
|
|
28
28
|
Deliberately **not** shared (real semantic differences, not drift): message parsing, media download / ingest, slash-command gating (slack `!cmd` alias + DM-only, feishu p2p-only, telegram's `bot.command` registration, wechat `/qr`), the send primitives (thread routing, passive reply window), and post-command effects (workspace restart, wechat's `switchTo` listener migration).
|
|
@@ -98,7 +98,7 @@ Routes: `packages/server/src/routes/telegram.ts`
|
|
|
98
98
|
1. Check user against `allowedUsers` whitelist
|
|
99
99
|
2. Resolve workspace (check path exists on disk)
|
|
100
100
|
3. If the text is a registered command, `runCommand` (→ `dispatchChannelCommand`) and stop
|
|
101
|
-
4. Otherwise `handleUserMessage` → `deliverInbound` ([shared skeleton](#shared-inbound-skeleton)): session resolve/create →
|
|
101
|
+
4. Otherwise `handleUserMessage` → `deliverInbound` ([shared skeleton](#shared-inbound-skeleton)): session resolve/create → busy hint → route `{chatId}` + `TelegramResponder` listener → deliver
|
|
102
102
|
|
|
103
103
|
Photos / documents / voice / video notes trigger the same flow with the saved-file note in the text.
|
|
104
104
|
|
|
@@ -138,6 +138,8 @@ Outbound: the `send-file` skill produces `MEDIA:<path>` markers. The responder s
|
|
|
138
138
|
- `.ogg/.oga` → `sendVoice`
|
|
139
139
|
- anything else → `sendDocument`
|
|
140
140
|
|
|
141
|
+
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 — or any failed send — is reported to the chat as `t('handler.upload_failed')` (`⚠️ 文件上传失败:name — error`), same as slack / feishu / wecom; before this it was only a server log line.
|
|
142
|
+
|
|
141
143
|
## Configuration
|
|
142
144
|
|
|
143
145
|
### BotFather setup
|
|
@@ -13,7 +13,7 @@ Halo server (9527) ──┤── channels/telegram/ ├── Sess
|
|
|
13
13
|
ilinkai.weixin.qq.com
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
All channels are peers — each one is a subscriber + caller against SessionManager. Common slash commands (`/help`, `/evo`, and the object commands `/session`, `/agent`, `/skill`, `/workspace`) live in `channels/shared/commands.ts`; each channel handler is a thin adapter that builds a `CommandContext` and formats the result for its transport. The inbound tail (session resolve →
|
|
16
|
+
All channels are peers — each one is a subscriber + caller against SessionManager. Common slash commands (`/help`, `/evo`, and the object commands `/session`, `/agent`, `/skill`, `/workspace`) live in `channels/shared/commands.ts`; each channel handler is a thin adapter that builds a `CommandContext` and formats the result for its transport. The inbound tail (session resolve → busy hint → responder wiring → deliver) is likewise shared — see [Shared inbound skeleton](telegram.md#shared-inbound-skeleton).
|
|
17
17
|
|
|
18
18
|
## Data model
|
|
19
19
|
|
|
@@ -46,11 +46,12 @@ Sub-sessions inherit the parent's access level — a readonly channel can't dele
|
|
|
46
46
|
|
|
47
47
|
WeChat bot accounts are stored in the unified channel DB: `~/.halo/secrets/channels/channels.db`, table `channel_accounts` with `channel_type = 'wechat'`. See [storage.md](storage.md#channel_accounts) for the full schema.
|
|
48
48
|
|
|
49
|
-
WeChat-specific config JSON fields: `botToken`, `baseUrl`, `userId`, `syncBuf`, `lastActiveChatId`, `contextTokens`.
|
|
49
|
+
WeChat-specific config JSON fields: `botToken`, `baseUrl`, `userId`, `syncBuf`, `lastActiveChatId`, `contextTokens`, `contextTokenAts`.
|
|
50
50
|
|
|
51
51
|
- `userId` is the QR-bind owner (the ilink_user_id of whoever scanned the QR code to register this bot). Used by the wechat cron-dispatcher as the default proactive-send target — sending here means cron output goes back to the bot's owner, which is the common-case "report to me on a schedule" intent.
|
|
52
52
|
- `lastActiveChatId` is a runtime cache of the most recent inbound `from_user_id`. Written by `rememberLastActiveChat()` in `channels/shared/accounts.ts` with a per-process hash so unchanged values never touch the db (idempotent — hot path stays in memory). Used as a fallback for cron sends in the shared-bot case where you want to reply to whoever talked last.
|
|
53
53
|
- `contextTokens` is a `Record<userId, context_token>` — the most recent inbound `context_token` per user, written by `rememberWechatContextToken()` (same in-process dedupe / write-on-change shape as `lastActiveChatId`). The ilink gateway expects every outbound `sendmessage` to echo the recipient's latest inbound token; the chat-reply path already carries it in the in-memory bridge route, but cron dispatch runs long after the inbound and needs a persisted copy.
|
|
54
|
+
- `contextTokenAts` is the parallel `Record<userId, epochMs>` of when each token arrived, written in the same `patchConfig` call. The token also **expires**: the gateway rejects a stale one with the same `ret=-2`, and only a new inbound message from that user refreshes it (window not fixed — observed rejected at 12 min and 79 min after the last inbound, accepted at 68 min). The stamp travels with the token (`WxRoute.contextTokenAt` in memory, replayed by `restoreReplyRoutes` after a restart, read by the cron dispatcher) purely so a `ret=-2` can say `context_token age 79m` vs `context_token missing`; accounts whose token predates the stamp report `age unknown`.
|
|
54
55
|
|
|
55
56
|
### Proactive sending (cron)
|
|
56
57
|
|
|
@@ -62,7 +63,7 @@ The wechat cron-dispatcher (`channels/wechat/cron-dispatcher.ts`) registers itse
|
|
|
62
63
|
|
|
63
64
|
WeChat is single-recipient per dispatch (no fan-out across `allowedUsers` like telegram has). If none of the three yields an id, dispatch fails with a clear "no wechat target — bind the account first" message.
|
|
64
65
|
|
|
65
|
-
The text is chunked exactly like a chat reply — `splitText(text, WECHAT_TEXT_LIMIT)` (3500 chars, see [Event coalescing](#event-coalescing-wechatresponder)) — and the chunks are sent sequentially so a long report arrives in order. Before this, a whole report went out in one `sendmessage` call and anything over the gateway's 16 KB ceiling failed outright with `ret=-2 "prepare failed"` (the stock-report job hit it routinely). A mid-chunk failure rethrows as `chunk i/n: <error>`, so the run row's dispatch result shows how much of the report already landed (chunks before `i` were delivered). Every chunk carries the recipient's persisted `context_token` (`config.contextTokens[chatId]`, see above) — the ilink gateway expects outbound to echo the latest inbound token, and sending without it was the second `ret=-2` cause: 6 failures in 10 days on payloads far under 16 KB, one of them 6 minutes after the user had just been chatting with the bot, which rules out the earlier "no recent inbound message" theory. Accounts with no inbound since 1.1.8 have no token yet and still send bare (as before) until the owner's next message. `api.ts`'s `ret=-2` hint names
|
|
66
|
+
The text is chunked exactly like a chat reply — `splitText(text, WECHAT_TEXT_LIMIT)` (3500 chars, see [Event coalescing](#event-coalescing-wechatresponder)) — and the chunks are sent sequentially so a long report arrives in order. Before this, a whole report went out in one `sendmessage` call and anything over the gateway's 16 KB ceiling failed outright with `ret=-2 "prepare failed"` (the stock-report job hit it routinely). A mid-chunk failure rethrows as `chunk i/n: <error>`, so the run row's dispatch result shows how much of the report already landed (chunks before `i` were delivered). Every chunk carries the recipient's persisted `context_token` (`config.contextTokens[chatId]`, see above) — the ilink gateway expects outbound to echo the latest inbound token, and sending without it was the second `ret=-2` cause: 6 failures in 10 days on payloads far under 16 KB, one of them 6 minutes after the user had just been chatting with the bot, which rules out the earlier "no recent inbound message" theory. Accounts with no inbound since 1.1.8 have no token yet and still send bare (as before) until the owner's next message. The third `ret=-2` cause is an **expired token** (see `contextTokenAts` above) — a push that echoes a token from an old inbound is rejected once the gateway's validity window has passed, and nothing halo does refreshes it; the recipient has to message the bot again. `api.ts`'s `ret=-2` hint names all three causes (oversized payload / no token / expired token) and appends the echoed token's age (`context_token age 79m` · `context_token missing` · `context_token age unknown`), also written as a `[WeChat:sendmessage] ret=-2 …` warn line.
|
|
66
67
|
|
|
67
68
|
## Modules
|
|
68
69
|
|
|
@@ -101,7 +102,7 @@ loop {
|
|
|
101
102
|
1. Extract `from_user_id` / text + media items / `context_token`
|
|
102
103
|
2. Process inbound media: images → base64 attached to the agent message; voice / video / files → saved under the workspace with a `[voice saved: ...]` etc. marker appended to the text
|
|
103
104
|
3. If the text begins with `/`, dispatch a slash command (see below) — if it returns `handled`, stop here
|
|
104
|
-
4. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)), which caches `lastActiveChatId`, resolves-or-creates the session,
|
|
105
|
+
4. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)), which caches `lastActiveChatId`, resolves-or-creates the session, sends a busy/compacting hint when needed, refreshes the reply route (`wxRoute(fromUserId, context_token)`), attaches the `WechatResponder` listener once, and delivers: clean text to the UI log, `[channel: wechat | user: <id>]\n<text>` to the agent (so it knows how to address the media-send skill)
|
|
105
106
|
|
|
106
107
|
## Slash commands
|
|
107
108
|
|
|
@@ -127,7 +128,8 @@ WeChat `sendMessage` is block-send, while LLMs stream. The current strategy is "
|
|
|
127
128
|
- Every chunk goes through one serialized send chain per responder (`sendTail`, same as Slack / Feishu) so a long reply arrives in order; `close()` returns the drain promise and `InboundBridge` keeps the reply route alive until it settles
|
|
128
129
|
- `error` → immediate flush + send a `[错误] …` message; `system` → immediate flush + send a `[系统] …` message
|
|
129
130
|
- Tool calls / tool results / thinking are dropped (detail lives in the web UI)
|
|
130
|
-
- Media: the agent emits `MEDIA: <path>` markers, which the responder extracts and turns into actual media uploads.
|
|
131
|
+
- Media: the agent emits `MEDIA: <path>` markers, which the responder extracts and turns into actual media uploads. A `full` account may send any readable path (its shell / file tools are unrestricted anyway); other access levels are limited to the account's workspace or the OS temp dir (`/tmp`) — `isMediaPathAllowed(path, workspace, accessLevel)`. A blocked path (e.g. another workspace's files) throws inside `sendMedia`, so it rides the responder's `onSendError` path and lands in the session log as a `⚠️ WeChat delivery failed: sendMedia … media path not allowed` notification instead of being dropped silently
|
|
132
|
+
- Send failures are surfaced, never retried: when `sendText` / `sendMedia` rejects (typically the gateway's `ret=-2`), `dispatchChunk` logs it and calls the optional `onSendError(message)` dep; the handler wires that to `sm.appendNotification(sessionId, '⚠️ WeChat delivery failed: …')`, so the session log shows the turn ran but its reply never reached WeChat. `appendNotification` only appends a `notification` row and persists — it emits no event, which matters: a `system` event would loop back into this responder's `case 'system'` and try to send to WeChat again
|
|
131
133
|
|
|
132
134
|
**The responder never captures its recipient.** Both `sendText` and `sendMedia` read `bridge.getRoute(sessionId)` at send time, so a `{fromUserId, contextToken}` refreshed by a later inbound message takes effect immediately. Closing over `fromUserId` at listener-registration time was the A-M2 bug: after a full-access `/session switch` moved a session to another user, the listener kept replying to the *first* one. `contextToken` (WeChat's passive-reply-window credential) is carried over between messages **only while the user is the same** — a new user's route starts without the old token rather than replaying it against the new recipient. `/session switch` additionally calls `bridge.dropListener(oldSid)` before wiring the target so the abandoned session doesn't keep a live responder.
|
|
133
135
|
|
|
@@ -133,7 +133,7 @@ All frames are JSON `{ cmd, headers: { req_id }, body }`; responses echo `header
|
|
|
133
133
|
5. Group text: strip leading `@token` mentions (`/^(@\S+\s*)+/`). WeCom only delivers group messages that `@` the bot, so there is no `shouldRespond` check. A multi-word bot name leaves its tail as residue — preferable to guessing the name and eating the user's first word
|
|
134
134
|
6. Slash command dispatch — **single chat only** (a group's shared session belongs to everyone). `/workspace switch` persists the new path on the account row; the reply goes out via `replyStream`
|
|
135
135
|
7. Skip if neither text/notes nor images remain
|
|
136
|
-
8. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)): create or retrieve the session (with inherited access level)
|
|
136
|
+
8. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)): create or retrieve the session (with inherited access level); busy / compacting hint via `replyStream`; refresh the route and attach the `WecomResponder` listener once; `sm.sendUserMessage(sessionId, agentInput, images?)` with `[channel: wecom | user: <userid>]` (+ ` | thread: <chatid>` in groups)
|
|
137
137
|
|
|
138
138
|
## Event coalescing (WecomResponder)
|
|
139
139
|
|
|
@@ -153,7 +153,7 @@ Chunk sends are serialized per responder (`sendTail` promise chain, audit A-L3)
|
|
|
153
153
|
|
|
154
154
|
**Inbound:** text, voice (as transcript), images (vision + saved), mixed (text + images), files, videos — single chat only for everything but text. Saved under `<workspace>/.halo/assets/wecom/inbound/<accountId>/<date>/`.
|
|
155
155
|
|
|
156
|
-
**Outbound routing** (`sendWecomMedia
|
|
156
|
+
**Outbound routing** (`sendWecomMedia`; for non-`full` accounts the path must be under the workspace or the OS temp dir — `isMediaPathAllowed(path, workspace, accessLevel)`, `full` accounts may send any readable path; a blocked path surfaces as the same `t('handler.upload_failed')` reply as a failed upload):
|
|
157
157
|
- `.png` / `.jpg` / `.jpeg` / `.gif` → `uploadMedia(type: 'image')` → `replyMedia('image')`
|
|
158
158
|
- `.mp4` → `uploadMedia(type: 'video')` → `replyMedia('video')`
|
|
159
159
|
- everything else (`.webp` / `.bmp` / `.mov` / documents / audio) → `uploadMedia(type: 'file')` → `replyMedia('file')` — WeCom's `image` accepts png/jpg/gif only and `video` mp4 only
|
|
@@ -82,31 +82,37 @@ Optional `chat` fields:
|
|
|
82
82
|
|
|
83
83
|
## Server → Client
|
|
84
84
|
|
|
85
|
-
Source: [event-processor.ts:
|
|
85
|
+
Source: [event-processor.ts:53-129](../../../packages/server/src/ws/event-processor.ts#L53) `sendWsNotification` switch.
|
|
86
86
|
|
|
87
87
|
### Agent event → WS message mapping
|
|
88
88
|
|
|
89
89
|
| Agent event | WS type | Fields |
|
|
90
90
|
|---|---|---|
|
|
91
|
-
| `
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
91
|
+
| `thinking_delta` | `chat:thinking` | text, agentName, taskId, turnId, sessionId |
|
|
92
|
+
| `stream_delta` | `chat:stream` | text, agentName, taskId, turnId, sessionId |
|
|
93
|
+
| `thinking` | `chat:thinking` | text, agentName, taskId, turnId, sessionId — **not forwarded** when `streamed: true` |
|
|
94
|
+
| `stream` | `chat:stream` | text, agentName, taskId, turnId, sessionId — **not forwarded** when `streamed: true` |
|
|
95
|
+
| `agent_start` | `agent:start` | agentName, task, taskId, sessionId |
|
|
96
|
+
| `agent_done` | `agent:done` | agentName, taskId, sessionId |
|
|
97
|
+
| `tool_call` | `agent:tool_call` | tool, toolUseId, input, agentName, taskId, turnId, sessionId |
|
|
98
|
+
| `tool_result` | `agent:tool_result` | result, toolUseId, agentName, taskId, durationMs, sessionId |
|
|
99
|
+
| `followup_start` / `queued_message` | `chat:followup` | agentName, sessionId |
|
|
100
|
+
| `usage` (no taskId) | `chat:usage` | contextTokens, outputTokens, turnId, modelId, usage, sessionId |
|
|
99
101
|
| `complete` | `chat:complete` | sessionId |
|
|
100
|
-
| `context` | `agent:context` | agentName, systemPrompt, taskId |
|
|
101
|
-
| `system` | `chat:system` | text |
|
|
102
|
-
| `error` | `error` | error, agentName, taskId |
|
|
103
|
-
| `user` (report, no taskId) | `chat:user` | text |
|
|
102
|
+
| `context` | `agent:context` | agentName, systemPrompt, taskId, sessionId |
|
|
103
|
+
| `system` | `chat:system` | text, taskId, agentName, sessionId |
|
|
104
|
+
| `error` | `error` | error, agentName, taskId, sessionId |
|
|
105
|
+
| `user` (report, no taskId) | `chat:user` | text, sessionId |
|
|
104
106
|
|
|
105
|
-
|
|
107
|
+
Every provider streams (Bedrock, the Anthropic-Messages providers anthropic / mimo / minimax / qwen, the OpenAI-family openai / deepseek / kimi / zhipu / doubao / hunyuan, and Mantle aws-bedrock-mantle / aws-bedrock-openai): they emit `stream_delta` / `thinking_delta` per chunk *during* the model call, and the whole `stream` / `thinking` that follows the call is stamped `streamed: true` — the wire frame is the same `chat:stream` / `chat:thinking` either way (the admin appends by `turnId`), so the whole event is dropped to avoid rendering the text twice. A provider that never calls `onDelta` would still send the whole event only, exactly as before streaming existed.
|
|
108
|
+
|
|
109
|
+
`sessionId` is the session the emitting listener is attached to (`client.sessionId` at registration; `null` when unknown). The listener stays bound to that session until the client re-subscribes, so after a session switch the old turn's frames keep arriving until it completes — the admin (`isForCurrentSession` in `chat-handlers.ts`) drops any frame whose `sessionId` is set and differs from the loaded session. Frames without one (`compact:*`, `session:compacted`, the detached-buffer replays, handler-local sends) pass through. `chat:complete`, `state:snapshot` and `session:*` are never filtered.
|
|
110
|
+
|
|
111
|
+
`chat:thinking` / `chat:stream` / `chat:followup` / `agent:tool_call` / `agent:tool_result` additionally carry `replay: true` (plus `sessionId`) when synthesized by the reattach path (never on live events) — see [Reconnect flow](#reconnect-flow) step 6.
|
|
106
112
|
|
|
107
113
|
`chat:system` producers (`session-manager.ts`'s `stop` event handling): a `max_tokens` stop emits `⚠️ [<agent>] Response truncated: output token limit reached.`; a `refusal` stop (Anthropic `stop_reason: "refusal"`, HTTP 200 — the model declined, not an error) emits `⚠️ [<agent>] Model declined to respond (<category>): <explanation> — …` suggesting `/new` (see [session.md](session.md#resilient-execution-loop)).
|
|
108
114
|
|
|
109
|
-
Server-internal flags on `AgentSessionEvent` that are **not** carried into the WS frame: `stream.final` (marks the turn's wrap-up text vs. pre-tool filler — consumed by channel responders and the cli, see [session.md](session.md#message-queue-and-drain)) and `complete.batchBoundary`. The admin renders every streamed block, so
|
|
115
|
+
Server-internal flags on `AgentSessionEvent` that are **not** carried into the WS frame: `stream.final` (marks the turn's wrap-up text vs. pre-tool filler — consumed by channel responders and the cli, see [session.md](session.md#message-queue-and-drain)), `stream.streamed` / `thinking.streamed` (the text already went out as deltas — decides whether the whole event is forwarded at all) and `complete.batchBoundary`. The admin renders every streamed block, so none is needed on the wire.
|
|
110
116
|
|
|
111
117
|
### Other Server → Client messages
|
|
112
118
|
|
|
@@ -121,7 +127,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
|
|
|
121
127
|
| `terminal:ready` / `terminal:output` / `terminal:exit` / `terminal:reattached` | TerminalManager | PTY output |
|
|
122
128
|
| `session:changed` | `SessionManager` (broadcast to all clients) | Root session list changed — re-fetch. Fires on root-session create *and* on each root turn `complete` (so channel-driven messages refresh the count/title/ordering, not just admin's own turns). |
|
|
123
129
|
| `session:switched` | handler.ts (this client only) | The server rebound this connection to a different session — see [switchTo rebind](#switchto-rebind--sessionswitched) |
|
|
124
|
-
| `goal:changed` | `writeGoalState` (`agents/goal-mode.ts`, broadcast to all clients) |
|
|
130
|
+
| `goal:changed` | `writeGoalState` (`agents/goal-mode.ts`, broadcast to all clients) | Retained legacy event: `{goalSessionId, workerSessionId, status, round, maxRounds}`, no workspace marker. Emitted on goal state writes for existing goal bindings, unchanged. |
|
|
125
131
|
| `session:cleared` | session:clear handler (this client only) | /session new complete — sent **after** `saveSession`, so the admin bumps its session-list bus on this event instead of guessing with a `setTimeout` |
|
|
126
132
|
| `session:deleted` | session:delete handler (this client only) | Session delete complete — `{sessionId}` |
|
|
127
133
|
| `chat:stopped` | `chat:stop` / `chat:interrupt` handlers (this client only) | Stop/interrupt acknowledged — `{sessionId}` |
|
|
@@ -129,6 +135,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
|
|
|
129
135
|
| `compact:started` / `compact:summarizing` / `compact:done` | compact handler | Compaction progress |
|
|
130
136
|
| `cron:job_changed` / `cron:run_changed` | `cron/runner.ts` + `routes/cron.ts` (broadcast) | Cron job/run state changed — the Cron tab re-fetches instead of polling. See [cron.md](cron.md). |
|
|
131
137
|
| `evolution:run_changed` / `evolution:apply_changed` | `evolution/ticker.ts` + `routes/evolution.ts` (broadcast) | Evolution run/apply state changed — drives the Evolution tab. See [evolution.md](evolution.md). |
|
|
138
|
+
| `extension:changed` | `extensions/watcher.ts` `rescanAndBroadcast` (broadcast to all clients) | `~/.halo/global/extensions/` changed (install / upgrade / uninstall, from the admin upload, the `extension` skill, or a manual `mv`/`rm`) — carries the **full** `ExtensionsSnapshot` (`{extensions, errors}`), not a diff, and only fires when the snapshot key differs from the last one. The admin replaces its extension list wholesale: the preview registry re-resolves open tabs (an upgraded extension remounts, or shows a banner if the tab is dirty; an uninstalled one falls back to the "no built-in preview" page) and the Settings → Extensions panel re-renders. Global, no workspace marker — extensions are server-wide. See [canvas-extensions.md](canvas-extensions.md). |
|
|
132
139
|
|
|
133
140
|
### `error` frames: optional `code`
|
|
134
141
|
|
|
@@ -182,9 +189,9 @@ reattach lifecycle is unchanged — this only affects command routing.
|
|
|
182
189
|
|
|
183
190
|
### switchTo rebind & `session:switched`
|
|
184
191
|
|
|
185
|
-
When a command result carries `switchTo` (e.g. `/new
|
|
192
|
+
When a command result carries `switchTo` (e.g. `/new`), the WS handler doesn't just report the new id — it **rebinds this client's event stream**: unsubscribe the old listener, set `client.sessionId`, `registerEventListener` on the target, then send `session:switched {sessionId}`. Without the rebind, streaming events from the switched-to session would never reach the connection — the listener would still point at the old id.
|
|
186
193
|
|
|
187
|
-
The same mechanics run
|
|
194
|
+
The same mechanics run when `handleChat` diverts a chat to a different session than the one the client is bound to (the retained legacy goal routing overlay, which still applies to any existing goal binding): the listener is rebound and `session:switched` is sent. On receipt the admin clears its chat store, sets the new session id, and **re-subscribes** to pull the disk-seeded snapshot so the target session's existing transcript renders (`chat-handlers.ts`).
|
|
188
195
|
|
|
189
196
|
### Message flow
|
|
190
197
|
|
|
@@ -19,18 +19,18 @@ Total effort: typically a few hours depending on how weird the upstream API is a
|
|
|
19
19
|
| Provider ID | Class | File | Notes |
|
|
20
20
|
|---|---|---|---|
|
|
21
21
|
| `aws-bedrock-claude-invoke` | `BedrockAgent` | `bedrock-agent.ts` | Anthropic Messages API via AWS SDK |
|
|
22
|
-
| `kimi` | `KimiAgent` | `kimi-agent.ts` | OpenAI-compatible, thinking (reasoning_content). Two API generations, branched on modelId: **K3** — vision (base64 data URLs only, no public-URL input), thinking always-on server-side, sole knob is top-level `reasoning_effort` (enum currently just `"max"`); **K2.6** — no vision, no `reasoning_effort` field at all, only the `thinking:{type:'enabled'|'disabled'}` toggle. Never send the K2.x `thinking` parameter to K3 |
|
|
23
|
-
| `deepseek` | `DeepSeekAgent` | `deepseek-agent.ts` | OpenAI-compatible, thinking (reasoning_content). Vision per model: `deepseek-flash` (V4.1-Flash) sees `image_url` data URLs; `deepseek-v4-pro` accepts the block but ignores it (live-probed 2026-09-24 with a 64×64 blue PNG) — registry `image` flag gates it |
|
|
24
|
-
| `minimax` | `MiniMaxAgent` | `minimax-agent.ts` | Anthropic Messages API (x-api-key). Two thinking schemes, branched on registry `thinking.mode`: **M3** (default) — vision, thinking off by default and enabled via `thinking:{type:'adaptive'}` (no levels, no budget_tokens), no `promptCaching` in the manifest on purpose (official docs scope `cache_control` to M2.x only; M3 has passive automatic caching); **M2.7** — no vision, thinking always-on (manual budget_tokens), prompt caching (5m, M2.7 doesn't report cache_creation_input_tokens) |
|
|
25
|
-
| `qwen` | `QwenAgent` | `qwen-agent.ts` | Aliyun Bailian Anthropic-compatible API, thinking enable/disable both honored, prompt caching (5m only), vision on Plus, max no vision |
|
|
26
|
-
| `hunyuan` | `HunyuanAgent` | `hunyuan-agent.ts` | Tencent Hy3 preview via OpenAI-compatible (tokenhub.tencentmaas.com), reasoning_effort low/high (no_think = omit), automatic prompt caching (cached_tokens), no vision |
|
|
27
|
-
| `doubao` | `DoubaoAgent` | `doubao-agent.ts` | ByteDance Doubao Seed-2.0 (pro/lite/mini/code) on Volcengine Ark, OpenAI-compatible + `thinking:{type:'enabled'/'disabled'}` (no `auto`), automatic prompt caching, no vision |
|
|
28
|
-
| `zhipu` | `ZhipuAgent` | `zhipu-agent.ts` | Zhipu GLM-5.x on open.bigmodel.cn, OpenAI-compatible. Output cap via `max_tokens` only (`max_completion_tokens` is silently ignored). Thinking branched on modelId: **glm-5.3\*** always thinks — always sends `thinking:{type:'enabled',clear_thinking:false}`, `reasoning_effort` only when low/high/max (`disabled` / other efforts 400); other ids (glm-5.2) honour `thinking:{type:'disabled'}`. Vision per model: `glm-5.3-flash` / `glm-5.3-flashx` see `image_url` data URLs; `glm-5.3` / `glm-5.2` are text-only and 400 on any image part (code 1210, classified `multimodal_4xx` so leftover history images degrade). Automatic caching, `prompt_tokens_details.cached_tokens` (included in `prompt_tokens`, subtracted). |
|
|
29
|
-
| `openai` | `OpenAIAgent` | `openai-agent.ts` | **Generic OpenAI-compatible**. Use for native OpenAI or any third-party OpenAI gateway (incl. Ollama / llama.cpp). User fills endpoint + model id; capabilities openly editable in the form. Forgiving about cache field names (`prompt_tokens_details.cached_tokens`, `prompt_cache_hit_tokens`, `cache_read_tokens` all read) and reasoning field names (`reasoning_content` for OpenAI o-series/DeepSeek, `reasoning` for Ollama/llama.cpp). |
|
|
30
|
-
| `anthropic` | `AnthropicAgent` | `anthropic-agent.ts` | **Generic Anthropic Messages**. Use for api.anthropic.com or any Anthropic-compatible gateway. User fills endpoint + model id. Standard `cache_control: ephemeral` with optional `ttl: '1h'`. |
|
|
31
|
-
| `mimo-token-plan-china` | `AnthropicAgent` | (reuses `anthropic-agent.ts`) | Xiaomi MiMo (UI name "Xiaomi MiMo"; the id keeps its historical name so existing configs don't break) — Anthropic-compatible gateway; no dedicated class, just a default endpoint in `model-runtime.ts` (`token-plan-cn.xiaomimimo.com/anthropic`). The key type picks the endpoint: Token Plan `tp-`/`ttp-` keys only work on `token-plan-{cn,sgp,ams}.xiaomimimo.com/anthropic`, pay-as-you-go `sk-` keys only on `api.xiaomimimo.com/anthropic` — all four ship as `endpointPresets` (pay-as-you-go live-probed 2026-09-24; sgp / ams from the docs only). V2.6 (`mimo-v2.6-pro` / `-flash`, both vision, 1M / 131072) is current; `mimo-v2.5(-pro)` kept in the template until the official 2026-10-21 deprecation, then drop |
|
|
32
|
-
| `aws-bedrock-mantle` | `MantleAgent` | `mantle-agent.ts` | OpenAI GPT-6 (Astra / Sol / Luna) and GPT-5.6 (Sol / Terra / Luna) on Amazon Bedrock via the **OpenAI Responses API** (not Completions/Converse/InvokeModel). Vision, reasoning via `reasoning.effort` (low/med/high/xhigh/max, no budget_tokens; on 5.6 `max` is accepted live but absent from the model's own validation list — measured ~6% more reasoning_tokens than xhigh; on Astra it's on the official list), `text.verbosity` (low/medium/high, same on both generations). **Region split** (live-probed 2026-09-24): us-east-1 serves GPT-6 Sol / Luna + every 5.6 model (404s Astra); us-east-2 serves only the 5.6 trio; us-west-2 serves Astra + 5.6 Terra / Luna. Template ships all three as `endpointPresets`, default is now us-east-1 (`openai.gpt-6-sol` default model); Astra agents pick us-west-2 in the form; `extractRegionFromHost` falls back to us-east-1. All GPT-6 variants: 1,050,000 context / 128,000 max output, same effort (none/low/medium/high/xhigh/max) + verbosity surface; input beyond 272K tokens bills at 2x, so cap `context.maxTokens` in agent.yaml unless that's intended. Never surfaces reasoning summaries — `output[].reasoning.summary` stays empty even with `reasoning.summary=detailed` while `reasoning_tokens` counts up (verified live), so no thinking text in the UI is upstream behavior, not a parsing gap. |
|
|
33
|
-
| `aws-bedrock-openai` | `MantleAgent` | (reuses `mantle-agent.ts`) | Frontier models on Bedrock via the **bedrock-runtime** host's OpenAI Responses surface (`https://bedrock-runtime.<region>.amazonaws.com/openai/v1` — AWS's recommended endpoint for new apps; Mantle is the compatibility one). Same wire format, so only a second `case` in `model-runtime.ts` + `extractRegionFromHost` accepting both hostnames (SigV4 signs with the host's region — a mismatched region 401s "Credential should be scoped to a valid region"). Rules that differ from Mantle: model ids must be cross-Region inference profiles (`global.*` / `us.*`; bare ids 400 "on-demand throughput isn't supported"); **no region split** — live-probed 2026-09-24, all
|
|
22
|
+
| `kimi` | `KimiAgent` | `kimi-agent.ts` | OpenAI-compatible, streaming via `fetchChatCompletionStream` (usage arrives in its own `choices: []` chunk), thinking (reasoning_content). Two API generations, branched on modelId: **K3** — vision (base64 data URLs only, no public-URL input), thinking always-on server-side, sole knob is top-level `reasoning_effort` (enum currently just `"max"`); **K2.6** — no vision, no `reasoning_effort` field at all, only the `thinking:{type:'enabled'|'disabled'}` toggle. Never send the K2.x `thinking` parameter to K3 |
|
|
23
|
+
| `deepseek` | `DeepSeekAgent` | `deepseek-agent.ts` | OpenAI-compatible, streaming via `fetchChatCompletionStream` (first chunk has `content: null` + `reasoning_content: ''`; the usage chunk still carries `choices`), thinking (reasoning_content). Vision per model: `deepseek-flash` (V4.1-Flash) sees `image_url` data URLs; `deepseek-v4-pro` accepts the block but ignores it (live-probed 2026-09-24 with a 64×64 blue PNG) — registry `image` flag gates it |
|
|
24
|
+
| `minimax` | `MiniMaxAgent` | `minimax-agent.ts` | Anthropic Messages API (x-api-key), streaming via `fetchAnthropicStream` — note `message_start.usage` is all zeros and the real input / cache counts arrive in `message_delta.usage` (the accumulator takes them from there). Two thinking schemes, branched on registry `thinking.mode`: **M3** (default) — vision, thinking off by default and enabled via `thinking:{type:'adaptive'}` (no levels, no budget_tokens), no `promptCaching` in the manifest on purpose (official docs scope `cache_control` to M2.x only; M3 has passive automatic caching); **M2.7** — no vision, thinking always-on (manual budget_tokens), prompt caching (5m, M2.7 doesn't report cache_creation_input_tokens) |
|
|
25
|
+
| `qwen` | `QwenAgent` | `qwen-agent.ts` | Aliyun Bailian Anthropic-compatible API, streaming via `fetchAnthropicStream` (SSE frames come as `data:{…}` with no space after the colon — the reader accepts both), thinking enable/disable both honored, prompt caching (5m only), vision on Plus, max no vision |
|
|
26
|
+
| `hunyuan` | `HunyuanAgent` | `hunyuan-agent.ts` | Tencent Hy3 preview via OpenAI-compatible (tokenhub.tencentmaas.com), streaming via `fetchChatCompletionStream` (`stream_options.include_usage` accepted; usage in its own `choices: []` chunk), reasoning_effort low/high (no_think = omit), automatic prompt caching (cached_tokens), no vision |
|
|
27
|
+
| `doubao` | `DoubaoAgent` | `doubao-agent.ts` | ByteDance Doubao Seed-2.0 (pro/lite/mini/code) on Volcengine Ark, OpenAI-compatible, streaming via `fetchChatCompletionStream` (usage in its own `choices: []` chunk) + `thinking:{type:'enabled'/'disabled'}` (no `auto`), automatic prompt caching, no vision |
|
|
28
|
+
| `zhipu` | `ZhipuAgent` | `zhipu-agent.ts` | Zhipu GLM-5.x on open.bigmodel.cn, OpenAI-compatible, streaming via `fetchChatCompletionStream` (tool-call arguments arrive complete in one fragment; usage chunk carries `choices`). Output cap via `max_tokens` only (`max_completion_tokens` is silently ignored). Thinking branched on modelId: **glm-5.3\*** always thinks — always sends `thinking:{type:'enabled',clear_thinking:false}`, `reasoning_effort` only when low/high/max (`disabled` / other efforts 400); other ids (glm-5.2) honour `thinking:{type:'disabled'}`. Vision per model: `glm-5.3-flash` / `glm-5.3-flashx` see `image_url` data URLs; `glm-5.3` / `glm-5.2` are text-only and 400 on any image part (code 1210, classified `multimodal_4xx` so leftover history images degrade). Automatic caching, `prompt_tokens_details.cached_tokens` (included in `prompt_tokens`, subtracted). |
|
|
29
|
+
| `openai` | `OpenAIAgent` | `openai-agent.ts` | **Generic OpenAI-compatible**. Use for native OpenAI or any third-party OpenAI gateway (incl. Ollama / llama.cpp). User fills endpoint + model id; capabilities openly editable in the form. Streaming via `fetchChatCompletionStream` — always sends `stream: true` + `stream_options: { include_usage: true }` (OpenAI's own field; Ollama ≥ 0.3 / vLLM / LM Studio accept it too). A gateway that rejects unknown fields would 400 — accepted trade-off, no non-streaming fallback. Forgiving about cache field names (`prompt_tokens_details.cached_tokens`, `prompt_cache_hit_tokens`, `cache_read_tokens` all read) and reasoning field names (`reasoning_content` for OpenAI o-series/DeepSeek, `reasoning` for Ollama/llama.cpp). |
|
|
30
|
+
| `anthropic` | `AnthropicAgent` | `anthropic-agent.ts` | **Generic Anthropic Messages**. Use for api.anthropic.com or any Anthropic-compatible gateway. User fills endpoint + model id. Streaming via `fetchAnthropicStream` (the gateway must support `stream: true` SSE — the reference Anthropic behaviour). Standard `cache_control: ephemeral` with optional `ttl: '1h'`. |
|
|
31
|
+
| `mimo-token-plan-china` | `AnthropicAgent` | (reuses `anthropic-agent.ts`) | Xiaomi MiMo (UI name "Xiaomi MiMo"; the id keeps its historical name so existing configs don't break) — Anthropic-compatible gateway, streams like `anthropic` (gateway built for Claude Code, which always streams; not live-probed); no dedicated class, just a default endpoint in `model-runtime.ts` (`token-plan-cn.xiaomimimo.com/anthropic`). The key type picks the endpoint: Token Plan `tp-`/`ttp-` keys only work on `token-plan-{cn,sgp,ams}.xiaomimimo.com/anthropic`, pay-as-you-go `sk-` keys only on `api.xiaomimimo.com/anthropic` — all four ship as `endpointPresets` (pay-as-you-go live-probed 2026-09-24; sgp / ams from the docs only). V2.6 (`mimo-v2.6-pro` / `-flash`, both vision, 1M / 131072) is current; `mimo-v2.5(-pro)` kept in the template until the official 2026-10-21 deprecation, then drop |
|
|
32
|
+
| `aws-bedrock-mantle` | `MantleAgent` | `mantle-agent.ts` | OpenAI GPT-6.1 Sol, GPT-6 (Astra / Sol / Luna) and GPT-5.6 (Sol / Terra / Luna) on Amazon Bedrock via the **OpenAI Responses API** (not Completions/Converse/InvokeModel). Vision, reasoning via `reasoning.effort` (low/med/high/xhigh/max, no budget_tokens; on 5.6 `max` is accepted live but absent from the model's own validation list — measured ~6% more reasoning_tokens than xhigh; on Astra it's on the official list), `text.verbosity` (low/medium/high, same on both generations). **Region split** (live-probed 2026-09-24): us-east-1 serves GPT-6.1 Sol + GPT-6 Sol / Luna + every 5.6 model (404s Astra; GPT-6.1 Sol probed 2026-09-30, us-east-1 only); us-east-2 serves only the 5.6 trio; us-west-2 serves Astra + 5.6 Terra / Luna. Template ships all three as `endpointPresets`, default is now us-east-1 (`openai.gpt-6-sol` default model); Astra agents pick us-west-2 in the form; `extractRegionFromHost` falls back to us-east-1. All GPT-6 variants: 1,050,000 context / 128,000 max output, same effort (none/low/medium/high/xhigh/max) + verbosity surface; input beyond 272K tokens bills at 2x, so cap `context.maxTokens` in agent.yaml unless that's intended. Never surfaces reasoning summaries — `output[].reasoning.summary` stays empty even with `reasoning.summary=detailed` while `reasoning_tokens` counts up (verified live), so no thinking text in the UI is upstream behavior, not a parsing gap. Streams (`stream: true`, Responses API SSE folded by `readResponsesStream` in the same file): `response.output_text.delta` frames go out live, the terminal `response.completed` / `response.incomplete` frame carries the full final response object; this host sends **no** `data: [DONE]` (live-probed 2026-09-29). |
|
|
33
|
+
| `aws-bedrock-openai` | `MantleAgent` | (reuses `mantle-agent.ts`) | Frontier models on Bedrock via the **bedrock-runtime** host's OpenAI Responses surface (`https://bedrock-runtime.<region>.amazonaws.com/openai/v1` — AWS's recommended endpoint for new apps; Mantle is the compatibility one). Same wire format, so only a second `case` in `model-runtime.ts` + `extractRegionFromHost` accepting both hostnames (SigV4 signs with the host's region — a mismatched region 401s "Credential should be scoped to a valid region"). Rules that differ from Mantle: model ids must be cross-Region inference profiles (`global.*` / `us.*`; bare ids 400 "on-demand throughput isn't supported"); **no region split** — live-probed 2026-09-24, all nine `global.*` ids answer 200 from us-east-1 / us-west-2 / eu-west-1 / ap-northeast-1 alike; **no server-side tools** — `tools:[{type:"web_search"}]` 400s "web search is not supported for this request", which is why the web-search skill's deep gear stays on Mantle. Ships the same seven **GPT-6.1 / 6 / 5.6** entries as the Mantle template (`global.openai.gpt-6.1-sol`, `global.openai.gpt-6-{astra,sol,luna}`, `global.openai.gpt-5.6-{sol,terra,luna}`; identical effort none/low/medium/high/xhigh/max + verbosity surface per each model's own validation error — except Astra and GPT-6.1 Sol 400 on `none`, which the runtime never sends), plus **Grok 4.6** (500K ctx, 2x input price past 200K, max output undisclosed → 64000 carry-over, effort **only** low/medium/high/xhigh — `none`/`max` 400, so no Max preset; vision OK; $2/$6) and **Kimi K3** (1M ctx / 131072 out, effort none…max all accepted, ladder advisory on short prompts; vision OK; $3/$15). All: implicit prompt caching verified (run 1 `cache_write_tokens`, runs 2+ `cached_tokens` ≈ prefix), no reasoning summaries. Streams like Mantle (same event vocabulary) but this host **does** append a trailing `data: [DONE]`, which `readSseJson` skips; grok-4.6 emits a `reasoning` output item (no summary text deltas) before the `message` item. |
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
@@ -57,7 +57,7 @@ export interface ModelRuntime {
|
|
|
57
57
|
}
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
`AgentLoop` handles the tool execution loop (call model → execute tools → loop). You only implement `callModel()` — the provider-specific API call. **
|
|
60
|
+
`AgentLoop` handles the tool execution loop (call model → execute tools → loop). You only implement `callModel(signal, onDelta?)` — the provider-specific API call. It always returns a `Promise<ModelCallResult>` containing the complete response; the base class `run()` method yields events per loop iteration. **Streaming is optional and per-provider**: if the upstream API streams, call `onDelta({ type: 'text_delta' | 'thinking_delta', text })` per chunk as it arrives — the loop yields those as `text_delta` / `thinking_delta` events during the call and re-arms the per-call timeout on each (so it becomes an idle timeout) — then still return the whole result. A non-streaming provider would simply ignore `onDelta`; today every built-in provider streams — Bedrock, the Anthropic-Messages providers (anthropic / mimo / minimax / qwen), the OpenAI-family (openai / deepseek / kimi / zhipu / doubao / hunyuan) and Mantle (aws-bedrock-mantle / aws-bedrock-openai). The fold from Anthropic stream events into a `ModelCallResult` lives in `anthropic-stream.ts` (`AnthropicStreamAccumulator`); for an HTTP gateway speaking Anthropic Messages, `fetchAnthropicStream({ url, headers, body, signal, onDelta, tag })` in the same file is the whole `callModel` body — it posts with `stream: true`, parses the SSE with the generic `readSseJson` reader in `sse.ts` (also skips the OpenAI `[DONE]` terminator), maps a mid-stream `event: error` frame onto the `[tag] <status> <type>: <message>` error shape `classifyModelError` parses, and re-throws `AbortError` if the body ended at the abort instant. For an OpenAI-compatible `chat/completions` endpoint, `fetchChatCompletionStream({ url, headers, body, signal, onDelta, tag })` in `openai-chat-stream.ts` is the transport half of `callModel` — it posts with `stream: true` + `stream_options: { include_usage: true }`, folds the `chat.completion.chunk` frames (text / `reasoning_content` (or the Ollama `reasoning` alias) appended and reported via `onDelta`; `tool_calls[]` fragments matched by `index`, the first fragment carrying `id` + `function.name`, later ones appending `function.arguments`; `finish_reason` / `usage` last-non-null-wins — the usage chunk arrives with `choices` on deepseek / zhipu and as `choices: []` on kimi / doubao / hunyuan) back into the non-streaming `choices[0].message` + `finishReason` + `usage` shape, and throws today's `[<Tag>Agent] API error <status>: <body>` on non-2xx — the agent's existing parse / usage code runs unchanged on the folded result. Mantle's Responses API needs no accumulator at all: `readResponsesStream` (module-level in `mantle-agent.ts`) reports `response.output_text.delta` / `response.reasoning_summary_text.delta` live and keeps the `response` object off the terminal `response.completed` / `response.incomplete` frame, which is the full final response in exactly the non-streaming JSON shape — the existing `output[]` / `incomplete_details` / `usage` parse runs on it unchanged; a stream that ends without a terminal frame throws the same `MantleEmptyResponse` marker as the empty-`output[]` glitch so `classifyModelError`'s `empty_response` retry covers both.
|
|
61
61
|
|
|
62
62
|
Two important constraints:
|
|
63
63
|
|
|
@@ -74,10 +74,11 @@ Two important constraints:
|
|
|
74
74
|
toolCalls: Array<{ id: string; name: string; input: unknown }>
|
|
75
75
|
usage: { inputTokens; outputTokens; totalTokens; cacheReadInputTokens?; cacheWriteInputTokens? }
|
|
76
76
|
durationMs?: number
|
|
77
|
+
ttftMs?: number // streaming providers only: time to first content chunk
|
|
77
78
|
}
|
|
78
79
|
```
|
|
79
80
|
|
|
80
|
-
The base class `run()` translates this into `AgentEvent`s (`text`, `thinking`, `tool_call`, `tool_result`, `usage`, `stop`) that the rest of Halo consumes.
|
|
81
|
+
The base class `run()` translates this into `AgentEvent`s (`text`, `thinking`, `tool_call`, `tool_result`, `usage`, `stop`; plus `text_delta` / `thinking_delta` during the call for streaming providers) that the rest of Halo consumes.
|
|
81
82
|
|
|
82
83
|
### Usage reporting convention
|
|
83
84
|
|
|
@@ -114,13 +115,15 @@ import type { ContentBlock, ModelCallResult, ToolDef } from './agent-loop.js'
|
|
|
114
115
|
export class MyProviderAgent extends AgentLoop {
|
|
115
116
|
constructor(config: MyConfig) { super(config.tools); ... }
|
|
116
117
|
|
|
117
|
-
protected async callModel(signal): Promise<ModelCallResult> {
|
|
118
|
+
protected async callModel(signal, onDelta?): Promise<ModelCallResult> {
|
|
118
119
|
const startTime = Date.now()
|
|
119
120
|
const messages = this.buildMessages() // translate AnthropicMessage[] → provider format
|
|
120
121
|
const tools = this.buildTools() // translate ToolDef[] → provider format
|
|
121
122
|
|
|
122
|
-
//
|
|
123
|
-
//
|
|
123
|
+
// call fetchChatCompletionStream({ url, headers, body, signal, onDelta, tag }) from
|
|
124
|
+
// openai-chat-stream.ts (see kimi-agent.ts) — it POSTs with stream: true, reports
|
|
125
|
+
// text / reasoning chunks through onDelta and returns the folded { message, finishReason, usage, ttftMs }
|
|
126
|
+
// Return ModelCallResult with assistantBlocks, stopReason, text, thinking, toolCalls, usage, durationMs, ttftMs
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
private buildMessages() { ... } // system + user/assistant/tool message conversion
|
|
@@ -375,7 +378,7 @@ The `MantleEmptyResponse` retry guard in session-manager is unrelated to this an
|
|
|
375
378
|
|
|
376
379
|
Keeping it a hard-coded switch is deliberate for now:
|
|
377
380
|
- Each provider has enough quirks (auth, response format, tool calling conventions, thinking replay rules) that a generic "provider interface with fetch client" would leak abstractions
|
|
378
|
-
- Each provider independently extends `AgentLoop` — no shared OpenAI-compatible base class, to avoid coupling when APIs diverge
|
|
381
|
+
- Each provider independently extends `AgentLoop` — no shared OpenAI-compatible base class, to avoid coupling when APIs diverge; the shared piece is a transport helper function (`fetchChatCompletionStream`, like `fetchAnthropicStream`), not a base class — each agent still owns its body / message conversion / usage mapping
|
|
379
382
|
- The Agent management UI reads the manifest directly; a runtime-registered plugin would need to notify the frontend dynamically
|
|
380
383
|
- One provider lands every few months, not every week
|
|
381
384
|
|
package/bundled-docs/dev/api.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
All REST endpoints are served by Hono on port 9527 at `/api/`.
|
|
4
4
|
|
|
5
|
-
Auth: most `/api/*` routes require a valid JWT cookie (`halo_token`). Exceptions in `PUBLIC_PATHS` (`middleware/auth.ts`) bypass the cookie: `/api/auth/login|check|logout` (but **not** `/api/auth/change-password`), the web-channel routes (`/api/web/chat|sessions|stop|history|subscribe|file`), `/api/show/state|session`, and `/api/metrics` — these are unauthenticated or use a web-channel `x-token` instead.
|
|
5
|
+
Auth: most `/api/*` routes require a valid JWT cookie (`halo_token`). Exceptions in `PUBLIC_PATHS` (`middleware/auth.ts`) bypass the cookie: `/api/auth/login|check|logout` (but **not** `/api/auth/change-password`), the web-channel routes (`/api/web/chat|sessions|stop|history|subscribe|file`), `/api/show/state|session`, and `/api/metrics` — these are unauthenticated or use a web-channel `x-token` instead. One more shape bypasses the cookie: `/api/extensions/<id>/<version>/<token>/<asset>` (`EXTENSION_ASSET_PATH`), where the route verifies a scoped asset token carried in the path — see [Canvas Preview Extensions](#canvas-preview-extensions).
|
|
6
6
|
|
|
7
7
|
**Trust model.** The admin cookie is a single-tenant, all-or-nothing credential: there is one password (`~/.halo/secrets/config.yaml`), one JWT secret, and no roles. A valid `halo_token` equals full control of the server process — every workspace's files (`/api/files/*` project-scoped, `/api/fs/browse` unrestricted), shell via the WS terminal, channel credentials, git credentials, cron jobs, and the ability to mint web-channel tokens at any access level. Routes under the cookie therefore don't re-check "who" beyond the cookie itself; per-user isolation is the job of channel accounts (`x-token` with `readonly` / `workspace` / `full`), not of the admin. Consequences worth knowing when adding routes: (1) never put a cookie-gated route in `PUBLIC_PATHS` to "make it easier for a client" — that's the entire boundary; (2) a route that accepts a path or id from the client still needs traversal / shape checks (`isSafeIdSegment`, `assertPathAllowed`), because the *web-channel* surface reuses the same helpers and is multi-user; (3) if multi-user admin ever lands, the first change is a role claim in the JWT plus per-route checks, and this paragraph becomes the checklist.
|
|
8
8
|
|
|
@@ -22,7 +22,7 @@ File: `packages/server/src/middleware/auth.ts`
|
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| POST | `/api/auth/login` | Password login, returns a JWT cookie |
|
|
24
24
|
| POST | `/api/auth/logout` | Clears the auth cookie (Set-Cookie `Max-Age=0`). In `PUBLIC_PATHS`. No server-side token blacklist — a copied JWT stays valid until natural expiry |
|
|
25
|
-
| GET | `/api/auth/check` | Validates the current token; refreshes stale tokens. Also returns `badge` (the `HALO_BADGE` env, `null` when unset) on both the 200 and the 401 so the login page can brand its favicon/title too |
|
|
25
|
+
| GET | `/api/auth/check` | Validates the current token; refreshes stale tokens. Also returns `badge` (the `HALO_BADGE` env, `null` when unset) on both the 200 and the 401 so the login page can brand its favicon/title too; the 200 additionally carries a boolean `goalModeEnabled` |
|
|
26
26
|
| POST | `/api/auth/change-password` | Change the admin password. **Not** in `PUBLIC_PATHS` — requires a valid JWT cookie. Persists the new scrypt hash to `~/.halo/secrets/config.yaml`; takes effect immediately (no restart), does **not** rotate `jwt_secret` so existing sessions stay signed in |
|
|
27
27
|
|
|
28
28
|
## Files
|
|
@@ -37,6 +37,7 @@ File: `packages/server/src/routes/files.ts`
|
|
|
37
37
|
| GET | `/api/files/stat?path=&projectId=` | Lightweight mtime + size |
|
|
38
38
|
| GET | `/api/files/download?path=&projectId=&inline=` | Download or inline-preview — streams the file (no full read into memory), supports `Range` (206 Partial Content) so `<video>`/`<audio>` can seek + partial-load; aborts the read if the client disconnects |
|
|
39
39
|
| PUT | `/api/files` | Save file (body: `{path, content, projectId}`) |
|
|
40
|
+
| PUT | `/api/files/raw?path=&projectId=[&expectMtime=]` | Replace an **existing** file's bytes with the raw request body (binary-safe; `PUT /api/files` is utf-8 text only). Used by canvas preview extensions' save path. 404 if the file doesn't exist (save never creates); 400 for a directory. `expectMtime` is the `modifiedAt` the client loaded from `/api/files/stat` (float ms — compared after `Math.round`): when the on-disk mtime differs → **409** `{error:'conflict', mtime, size}` so the client can offer overwrite (retry without / with the new `expectMtime`). Success → `{ok, path, mtime, size}` |
|
|
40
41
|
| POST | `/api/files/new` | Create empty file |
|
|
41
42
|
| POST | `/api/files/mkdir` | Create directory |
|
|
42
43
|
| POST | `/api/files/rename` | Rename / move |
|
|
@@ -78,6 +79,24 @@ Pagination: `limit` defaults to 100, capped at 1000. Same `(path, projectId)` re
|
|
|
78
79
|
- **csv** — streaming RFC 4180 tokenizer, memory O(page). Delimiter is sniffed from the header line (`,` / `;` / tab, comma wins ties); `.tsv` forces tab. `totalRows` is a lazy lower bound while `hasMore` is true (exact once the scan reaches EOF, to avoid re-reading the whole file on every page turn). A single row over 1MB is rejected with 400 (delimiter-flood / non-CSV guard).
|
|
79
80
|
- All four fold cells to JSON scalars: bigints beyond ±2^53 become exact strings, BLOB/binary becomes a `<blob N bytes>` placeholder, `Date` becomes ISO text.
|
|
80
81
|
|
|
82
|
+
## Canvas Preview Extensions
|
|
83
|
+
|
|
84
|
+
File: `packages/server/src/routes/extensions.ts` (+ `extensions/{registry,watcher,install}.ts`). Design: [design/canvas-extensions.md](../design/canvas-extensions.md).
|
|
85
|
+
|
|
86
|
+
Installed extensions are directories under `~/.halo/global/extensions/<id>/` with a `halo-extension.json` manifest. The server is the only validator; the admin trusts the snapshot it receives (`ExtensionsSnapshot` from `packages/core/src/protocol/extension-types.ts`).
|
|
87
|
+
|
|
88
|
+
| Method | Path | Auth | Purpose |
|
|
89
|
+
|---|---|---|---|
|
|
90
|
+
| GET | `/api/extensions` | cookie | Current snapshot `{ extensions: ExtensionInfo[], errors: ExtensionError[] }` — `errors` lists directories the scanner refused (bad / missing manifest) so the admin can show and uninstall them |
|
|
91
|
+
| GET | `/api/extensions/token` | cookie | Mint a scoped asset token: `{ token, expiresAt }` (JWT `{scope:'ext'}`, 24 h). The admin caches one per page and embeds it in the iframe URL |
|
|
92
|
+
| POST | `/api/extensions/install` | cookie | Multipart `file` = zip (≤ 100 MB, 413 above). Unpacks to a temp dir, validates the manifest (`id` must equal the directory / zip root, `entry` must exist), atomically replaces `~/.halo/global/extensions/<id>/`. Returns the resulting `ExtensionInfo`; 400 `{error}` for a bad zip / manifest |
|
|
93
|
+
| DELETE | `/api/extensions/:id` | cookie | Remove the directory. 400 for an invalid id shape, 404 if not installed. Returns `{ok, id}` |
|
|
94
|
+
| GET | `/api/extensions/:id/:version/:token/<asset>` | **path token, no cookie** | Static file from the extension directory. 401 unless `verifyScopedToken(token,'ext')`; 404 if `version` ≠ installed version or the asset escapes the directory. Response headers: `Access-Control-Allow-Origin: *`, `Referrer-Policy: no-referrer`, `Cache-Control: public, max-age=31536000, immutable` (the version segment is the cache key) |
|
|
95
|
+
|
|
96
|
+
**Why the asset route isn't cookie-authed.** The admin first hosted extensions in `<iframe sandbox="allow-scripts">` without `allow-same-origin` — an opaque origin. The document navigation still carries the cookie, but every subresource it loads (classic/module scripts, img, css, fetch, wasm, dynamic import, Worker) is a cross-site request with **no cookie**, and module/fetch/wasm need `Access-Control-Allow-Origin` too. A `?token=` query would be dropped when the document resolves `./viewer.js`-style relative URLs, so the token is a **path segment** that every relative URL inherits. The host now grants `allow-same-origin` (a cookie-auth proxy in front of halo — CloudFront + midway, oauth2-proxy — needs its *own* cookie on those requests, which only a same-origin iframe sends; see [design/canvas-extensions.md](../design/canvas-extensions.md#asset-serving-and-the-scoped-token)), so the admin cookie does travel again, but the route keeps the path token as its single auth path. `authMiddleware` lets exactly that path shape (`EXTENSION_ASSET_PATH = /^\/api\/extensions\/[^/]+\/[^/]+\/[^/]+\/./`) through without a cookie and the route verifies the token itself. The token is good for nothing else: `validateToken` (admin cookie / WS upgrade) refuses any JWT payload that carries a `scope`, so an asset token replayed as `halo_token` is a 401.
|
|
97
|
+
|
|
98
|
+
Install / uninstall don't broadcast themselves — the resulting rename / rm trips the `fs.watch` on `~/.halo/global/extensions/` (`extensions/watcher.ts`), the single notifier; both routes also call `rescanAndBroadcast()` so their response and the next `GET` already reflect the change. Changes reach every connected admin as the WS frame `extension:changed` (full snapshot — see [design/ws.md](../design/ws.md#other-server--client-messages)). All three cookie routes are "any logged-in admin" — the cookie has no access level, so install / delete cannot be restricted to full access (the agent-side `extension` skill is `requiresAccess: full`).
|
|
99
|
+
|
|
81
100
|
## Session Logs (unified)
|
|
82
101
|
|
|
83
102
|
File: `packages/server/src/routes/sessions.ts`
|
|
@@ -91,9 +110,8 @@ Unified session log API — list + read session files across all agents.
|
|
|
91
110
|
| GET | `/api/sessions/logs/:id/archive/:n?projectId=` | One archived UI-log segment — see [detail](#get-apisessionslogsidarchivenprojectidabs) |
|
|
92
111
|
| DELETE | `/api/sessions/logs/:id?projectId=` | Delete the session log (and all of its archive segments) |
|
|
93
112
|
| PATCH | `/api/sessions/logs/:id?projectId=` | Rename a session (admin-only) — updates the log file's title and the mirrored `agent_sessions.title`. Accepts any session id (root or sub-agent); the sidebar exposes the rename affordance on every row |
|
|
94
|
-
| GET | `/api/sessions/goal?projectId=` | Latest goal binding for the workspace (goal-mode banner / input-lock seed) — see [detail](#get-apisessionsgoalprojectidabs) |
|
|
95
113
|
|
|
96
|
-
The list endpoint returns flat metadata (id / agentId / agentName / title / timestamps / exchangeCount / parentSessionId / stoppedAt / contextTokens / totalOutputTokens / goalSessionId), served from the mirrored `agent_sessions` columns rather than by parsing each session file (rows predating the columns are backfilled from the file on first read). The frontend builds the tree from `parentSessionId
|
|
114
|
+
The list endpoint returns flat metadata (id / agentId / agentName / title / timestamps / exchangeCount / parentSessionId / stoppedAt / contextTokens / totalOutputTokens / goalSessionId — the last is a retained legacy field, non-null only on a goal-bound worker row; existing bindings are unchanged), served from the mirrored `agent_sessions` columns rather than by parsing each session file (rows predating the columns are backfilled from the file on first read). The frontend builds the tree from `parentSessionId`.
|
|
97
115
|
|
|
98
116
|
The get endpoint returns the full session file. If only `rawMessages` is present (no event-log `messages`), `convertRawMessages()` transforms it into display format on the fly.
|
|
99
117
|
|
|
@@ -433,7 +451,7 @@ Source: [packages/server/src/middleware/auth.ts:123-134](../../../packages/serve
|
|
|
433
451
|
|
|
434
452
|
```json
|
|
435
453
|
// 200 authenticated
|
|
436
|
-
{ "authenticated": true, "badge": "DEV" }
|
|
454
|
+
{ "authenticated": true, "badge": "DEV", "goalModeEnabled": false }
|
|
437
455
|
|
|
438
456
|
// 401 not authenticated
|
|
439
457
|
{ "authenticated": false, "badge": "DEV" }
|
|
@@ -445,6 +463,8 @@ can brand its favicon + title from the very first paint without a second
|
|
|
445
463
|
endpoint or a poll. Corollary: the value is readable **without authenticating**,
|
|
446
464
|
so it must stay a label, not a secret. See [env.md](./env.md).
|
|
447
465
|
|
|
466
|
+
`goalModeEnabled` (boolean) is present on the **200 only** — the 401 body has no such field. It is a snapshot of `config.goalModeEnabled` taken at server start (`general.goal_mode_enabled`, default `false`), so it changes only after a restart; the admin reads it on load. See [requirements/settings.md](../requirements/settings.md).
|
|
467
|
+
|
|
448
468
|
### POST `/api/auth/change-password`
|
|
449
469
|
|
|
450
470
|
Source: [packages/server/src/middleware/auth.ts](../../../packages/server/src/middleware/auth.ts). Admin-cookie authed (not in `PUBLIC_PATHS`). Validation order: wrong current password → 401; strength (≥8 chars, at least one letter and one digit) → 400 with the specific reason; new === old → 400. When `HALO_PASSWORD` env is set it overrides the stored hash at login, so a file rewrite would silently not take effect — the endpoint rejects with 400 instead.
|
|
@@ -666,25 +686,6 @@ Failure modes, in the order checked:
|
|
|
666
686
|
|
|
667
687
|
The `n > archiveCount` check is the point, not a formality: `archiveCount` in the active file is the archive write's commit marker, so a segment file beyond it exists only because a crash interrupted the two-step write and must never be served. See [design/session.md](../design/session.md#ui-log-archiving).
|
|
668
688
|
|
|
669
|
-
### GET `/api/sessions/goal?projectId=<abs>`
|
|
670
|
-
|
|
671
|
-
Source: `packages/server/src/routes/sessions.ts` (`findLatestGoal` in `agents/goal-mode.ts`)
|
|
672
|
-
|
|
673
|
-
Latest goal binding for the workspace — goals are serialized per workspace, so "the" goal is unambiguous. Refresh seed for the admin's goal banner / worker input lock: `goal:changed` WS pushes keep a live tab current, this endpoint restores state after a page reload. Returns `{ "goal": null }` when there is no goal or the latest one is `cleared` (a dismissed record, not a displayable state). See [design/goal-mode.md](../design/goal-mode.md).
|
|
674
|
-
|
|
675
|
-
```json
|
|
676
|
-
// 200
|
|
677
|
-
{
|
|
678
|
-
"goal": {
|
|
679
|
-
"goalSessionId": "goal_mabc123",
|
|
680
|
-
"workerSessionId": "sid_abc",
|
|
681
|
-
"status": "running", // intake | running | paused | halted | done
|
|
682
|
-
"round": 3,
|
|
683
|
-
"maxRounds": 50
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
```
|
|
687
|
-
|
|
688
689
|
### GET `/api/settings/schema?projectId=<abs>`
|
|
689
690
|
|
|
690
691
|
Resolves declared schema (from `models/<id>.yaml` `secrets:` and `skills/<id>/config.yaml`) against current settings, returning per-field source/state and a list of orphan keys. Drives the Settings page.
|
package/bundled-docs/dev/env.md
CHANGED
|
@@ -84,7 +84,7 @@ Three config file types, precedence **env vars > config.yaml / settings.yaml > c
|
|
|
84
84
|
|
|
85
85
|
`init.ts` seeds these on first run with a per-category policy. Refresh trigger: `halo setup` always re-runs the seed, and the server's startup check re-runs it automatically when `~/.halo/global/.template-version` is behind the bundled `TEMPLATE_VERSION` (see `init.ts:TEMPLATE_VERSION` + `index.ts` startup block).
|
|
86
86
|
|
|
87
|
-
- **Always overwritten** (platform-owned, refreshed when the template version moves): `~/.halo/global/{prompts,models,docs}/`, `INSTRUCTIONS.md`, the built-in agent ids (`default`, `executor`, `deep-executor`, `
|
|
87
|
+
- **Always overwritten** (platform-owned, refreshed when the template version moves): `~/.halo/global/{prompts,models,docs}/`, `INSTRUCTIONS.md`, the built-in agent ids (`default`, `executor`, `deep-executor`, `__evo_agent__`, `__score__`, `__apply_agent__`), built-in skill ids (`agent`, `skill`, `workspace`, `cron`, `acp`, `send-file`, `self`, `aws-knowledge`, `nova-web-search`, `halo`).
|
|
88
88
|
- **Built-in agents** keep the user's `model:` block on overwrite — the admin UI lets users change which model an agent uses, and that choice survives upgrades.
|
|
89
89
|
- **Optional skills** (`tavily-web-search`) install only when picked via `halo setup`; the opt-in list is `~/.halo/global/.installed-optional-skills`. Picked skills are force-overwritten alongside the always-overwritten set.
|
|
90
90
|
- **`secrets/config.yaml`** is leaf-merged: existing leaf `value`s preserved, new leaves added when a server upgrade introduces them.
|
|
@@ -103,7 +103,7 @@ Source: `packages/server/src/config.ts`
|
|
|
103
103
|
| `HALO_MAX_CONTEXT_TOKENS` | `200000` | — | Model max context |
|
|
104
104
|
| `HALO_SHELL_TIMEOUT` | `120000` | `config.yaml timeout.shell_exec` | Shell command timeout (ms) |
|
|
105
105
|
| `HALO_WEB_FETCH_TIMEOUT` | `10000` | `config.yaml timeout.web_fetch` | web_fetch timeout |
|
|
106
|
-
| `HALO_MODEL_TIMEOUT` | `1800000` | `config.yaml timeout.model_request` | Per model-call
|
|
106
|
+
| `HALO_MODEL_TIMEOUT` | `1800000` | `config.yaml timeout.model_request` | Per model-call idle timeout (ms, 30 min): every provider streams (Bedrock, the Anthropic-Messages providers anthropic / mimo / minimax / qwen, the OpenAI-family openai / deepseek / kimi / zhipu / doubao / hunyuan, and Mantle aws-bedrock-mantle / aws-bedrock-openai) and resets it on every chunk, so it fires only when the model goes silent for this long, not on a long but live answer. A bare fetch has no default timeout, so a half-open connection would hang the request forever; on expiry the call aborts and the agent loop retries with backoff. |
|
|
107
107
|
| `HALO_SESSION_GRACE` | `300000` | `config.yaml timeout.session_grace` | WS-disconnect session grace |
|
|
108
108
|
| `HALO_TERMINAL_GRACE` | `300000` | `config.yaml timeout.terminal_grace` | Terminal disconnect grace |
|
|
109
109
|
| `HALO_MAX_CACHED_SESSIONS` | `50` | — | In-memory session cache |
|