@turmind/halo 0.2.5 → 1.0.0
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 +5 -5
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/212-e217e00ce6cc2bdc.js +1 -0
- package/admin-out/_next/static/chunks/397-fc5a406d80d46ca7.js +1 -0
- package/admin-out/_next/static/chunks/app/page-635d9668d47a2b9e.js +1 -0
- package/admin-out/_next/static/css/57622245d7724ee0.css +3 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/agent.md +12 -2
- package/bundled-docs/design/cron.md +16 -3
- package/bundled-docs/design/goal-mode.md +141 -0
- package/bundled-docs/design/session.md +25 -0
- package/bundled-docs/design/ws.md +34 -3
- package/bundled-docs/dev/add-model-provider.md +17 -9
- package/bundled-docs/dev/api.md +27 -1
- package/bundled-docs/dev/deploy.md +4 -8
- package/bundled-docs/dev/desktop-packaging.md +20 -0
- package/bundled-docs/dev/env.md +10 -5
- package/bundled-docs/dev/tools.md +60 -4
- package/bundled-docs/guide/cli.md +3 -2
- package/bundled-docs/requirements/chat.md +9 -0
- package/bundled-docs/requirements/command.md +12 -1
- package/dist/index.js +2371 -1254
- package/package.json +1 -1
- package/templates/agents/goal/AGENT.md +165 -0
- package/templates/agents/goal/agent.yaml +34 -0
- package/templates/models/anthropic.yaml +1 -0
- package/templates/models/aws-bedrock-claude-invoke.yaml +7 -0
- package/templates/models/aws-bedrock-mantle.yaml +51 -11
- package/templates/models/deepseek.yaml +2 -0
- package/templates/models/doubao.yaml +4 -0
- package/templates/models/hunyuan.yaml +1 -0
- package/templates/models/kimi.yaml +49 -13
- package/templates/models/mimo-token-plan-china.yaml +2 -0
- package/templates/models/minimax.yaml +37 -5
- package/templates/models/openai.yaml +1 -0
- package/templates/models/qwen.yaml +2 -0
- package/templates/schema.sql +3 -1
- package/admin-out/_next/static/chunks/212-170855bed19f67f3.js +0 -1
- package/admin-out/_next/static/chunks/307-d7a99f9a4a436ec3.js +0 -1
- package/admin-out/_next/static/chunks/app/page-96fce52cfe97cc9b.js +0 -1
- package/admin-out/_next/static/css/6f53bae6f1952300.css +0 -3
- /package/admin-out/_next/static/{P60h_WmaPcXYhIabUg6I2 → gzvax-Lg7K157j0rQJHje}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{P60h_WmaPcXYhIabUg6I2 → gzvax-Lg7K157j0rQJHje}/_ssgManifest.js +0 -0
|
@@ -43,6 +43,19 @@ These sessions also do **not** get an `agent_sessions` row in the workspace's `h
|
|
|
43
43
|
|
|
44
44
|
Full field list in [storage.md](storage.md).
|
|
45
45
|
|
|
46
|
+
### Exchange deletion (soft UI + hard raw)
|
|
47
|
+
|
|
48
|
+
`deleteExchange(sessionId, userOrdinal)` lets the user drop a single exchange (one user turn + all its responses) from the admin chat. It treats the two streams **asymmetrically** — the point is to free LLM context while keeping a visible audit trail:
|
|
49
|
+
|
|
50
|
+
- **`messages` (UI log): soft delete.** The target user message and every following message up to (not including) the next user message get `deleted: true`. The array length is unchanged, so `messageCount` semantics hold and the turn stays rendered — greyed out in the admin. There is no undo; a deleted exchange hides its own Delete button.
|
|
51
|
+
- **`rawMessages` (LLM-facing): physical delete of the whole turn.** Removed from the matched user-turn start through to the next user-turn start, so `tool_use` / `tool_result` pairs are never split (an orphaned `tool_result` would make every subsequent API call error out). `repairConversationMessages` then cleans the seam.
|
|
52
|
+
|
|
53
|
+
**Turn matching.** The raw turn is located by comparing the UI user text (after stripping the server-added `[图片已保存: …]` marker lines) against each raw user turn's text blocks, with an occurrence-rank tiebreak so duplicate prompts map to the right turn. **If no raw turn matches** (e.g. the turn was already compacted away) the raw log is left untouched — the UI soft-delete still lands (silent degrade).
|
|
54
|
+
|
|
55
|
+
**Ordinal alignment (the sharp edge).** `userOrdinal` is the 0-based index of the target user turn, counted **excluding `taskId` (sub-agent) messages** — matching the admin's `isMainConversationMessage` filter, which is what the frontend counts on. Both the ordinal-locate loop and the duplicate-rank loop skip `taskId` messages; if they didn't, a sub-agent's injected user turn in the root log would drift the count and delete the wrong exchange.
|
|
56
|
+
|
|
57
|
+
**Memory/disk sync.** A live session mutates `agent.messages` in place then `saveAgentState`; a cold session is edited directly on the `.json` file (read-merge-write). Rejected with `running` / `compacting` while a turn is in flight (mutating raw mid-turn would corrupt the in-flight conversation). Refresh is push-based: the active session gets a `state:snapshot`, other open sessions pick it up via the existing `.halo/sessions/` file watcher — no new WS message. Entry point: WS `exchange:delete` → `handler.ts:handleExchangeDelete` (see [ws.md](ws.md)).
|
|
58
|
+
|
|
46
59
|
## SessionManager
|
|
47
60
|
|
|
48
61
|
File: `packages/server/src/agents/session-manager.ts`
|
|
@@ -59,6 +72,7 @@ Manages every agent session's lifecycle (root + sub-agent). Each session is 1:1
|
|
|
59
72
|
| `interruptSession(sessionId)` | Abort the in-flight turn now (fire-and-forget); `interruptRequested` is set so the unwind repairs rather than errors, then the queued message drains. Shared by esc and the `interrupt_session` tool |
|
|
60
73
|
| `stopSession(sessionId)` | Fold the whole `messageQueue` into `agent.messages` (preserve, don't drop), abort + repair, no re-run, sets `stoppedAt`. Cascades to descendants |
|
|
61
74
|
| `deleteSession(sessionId)` | Cascade-delete a session and all descendants (SQLite) |
|
|
75
|
+
| `deleteExchange(sessionId, userOrdinal)` | Delete one exchange — soft-mark it in the UI log, physically remove the whole turn from `rawMessages`. See [Exchange deletion](#exchange-deletion-soft-ui--hard-raw). Rejects while running/compacting |
|
|
62
76
|
| `ensureSession(sessionId)` | Restore agent from disk if not in memory (calls `loadAgentState` internally) |
|
|
63
77
|
| `registerEventListener(rootSessionId, handler)` | Event routing per session tree |
|
|
64
78
|
| `unregisterEventListener(rootSessionId)` | Cancel listener |
|
|
@@ -116,6 +130,8 @@ Hierarchical encoding: `root_id>child_segment>grandchild_segment`.
|
|
|
116
130
|
|
|
117
131
|
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.
|
|
118
132
|
|
|
133
|
+
The full finally-chain order is `tryReportToParent → deliverGoalRound → releaseSession`: after the parent-report check, goal-bound **root** sessions get their round report delivered to the goal session (fire-and-forget; a no-op for everyone else — see [goal-mode.md](goal-mode.md#the-delivery-point-delivergoalround)), then the session is released.
|
|
134
|
+
|
|
119
135
|
- **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. Mid-tier parents are excluded by design — their `tryReportToParent` bubble-up already gates them on a fully-drained subtree.
|
|
120
136
|
|
|
121
137
|
- **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.
|
|
@@ -275,8 +291,17 @@ Frontend network issues don't affect the backend:
|
|
|
275
291
|
| **Transient server-side error (5xx / timeout)** | Same exponential backoff as throttling — see [Transient server-error classification](#transient-server-error-classification) below |
|
|
276
292
|
| Transient transport error (`fetch failed`, `ECONNRESET`, headers timeout, …) | Short backoff (`1s * 2^attempt` + jitter), retry |
|
|
277
293
|
| Corrupted messages (`tool_use ids without tool_result`) | Repair + retry |
|
|
294
|
+
| **4xx multimodal rejection** (`Multimodal data is corrupted` / `Could not process image`) | Replace all image blocks in history with text placeholders, persist, retry — see [Multimodal 4xx degrade](#multimodal-4xx-degrade) below |
|
|
278
295
|
| Unrecoverable error | Report to user, stop |
|
|
279
296
|
|
|
297
|
+
**Abort reasons are normalized through the `abortReason()` helper** — all three abort call sites (graceful interrupt after `tool_result`, `interruptSession`'s hard interrupt, `stopSession`'s stop) wrap the reason string in `new DOMException(reason, 'AbortError')` instead of passing it raw. Node 22 gotcha: `fetch` rejects with the abort reason **as-is**, so `controller.abort('interrupt')` surfaced the bare string `'interrupt'` (not an Error, `errName === ''`) in `runAgentTurn`'s catch — it missed the AbortError branch and logged a fake "Unrecoverable: interrupt" error. Contract pinned by a queue-semantics test.
|
|
298
|
+
|
|
299
|
+
### Multimodal 4xx degrade
|
|
300
|
+
|
|
301
|
+
A rejected image block doesn't just fail one turn: history is replayed wholesale with every request, so the same block re-fails **all** later requests and the session is permanently bricked. When a 4xx carries a known multimodal-rejection fingerprint (`Multimodal data is corrupted` / `Could not process image` — deliberately narrow: a miss keeps current behavior, a false positive would strip images on an unrelated 400), `replaceImageBlocks()` swaps **every** image block in history — top-level *and* nested inside `tool_result` content (e.g. `view_image`) — for a `[image removed: …]` text placeholder. The current turn's input blocks are degraded the same way (the retry re-coalesces them into the trailing user message, so a live rejected image would walk right back into history). State is persisted immediately via `saveAgentState` (a crash before turn-end must not resurrect the rejected blocks from disk), then the turn retries. Naturally once-only: after a degrade no image blocks remain, so a repeat error finds `replaced === 0` and falls through to Unrecoverable.
|
|
302
|
+
|
|
303
|
+
Prevention at the entry boundary: `buildInput` whitelists inbound image media types against `VISION_IMAGE_MIME_TYPES` (jpeg/png/gif/webp — single source in `channels/shared/media-store.ts`, shared with the web channel's inbound filter), so a bmp/tiff/empty `file.type` from the admin WS paste fallback never reaches the model in the first place.
|
|
304
|
+
|
|
280
305
|
### Transient server-error classification
|
|
281
306
|
|
|
282
307
|
The transient-5xx branch is the one that all providers share, and getting the **HTTP status** out of a failure is the crux — without it, a generic Bedrock 500 would kill the turn on the first attempt.
|
|
@@ -12,16 +12,30 @@ File: `packages/server/src/ws/handler.ts`
|
|
|
12
12
|
|
|
13
13
|
### Server-side keepalive
|
|
14
14
|
|
|
15
|
-
The server pings every connection at the WS protocol level every 10 s (keeps reverse-proxy idle timeouts from closing the socket). It tolerates **2 consecutive missed pongs** (~20-30 s of silence) before `ws.terminate()` — a single miss is routinely just laptop sleep/wake or a browser event-loop stall, not a dead peer.
|
|
15
|
+
The server pings every connection at the WS protocol level every 10 s (keeps reverse-proxy idle timeouts from closing the socket). It tolerates **2 consecutive missed pongs** (~20-30 s of silence) before `ws.terminate()` — a single miss is routinely just laptop sleep/wake or a browser event-loop stall, not a dead peer. Protocol-level pings serve the *server's* view only — browser JS never sees them — so the client runs its own **application-level** probe on top: it sends `{type:'__ping__'}` and the server answers `{type:'__pong__'}`, giving the client an inbound frame it can observe.
|
|
16
16
|
|
|
17
17
|
### Client-side liveness & reconnect
|
|
18
18
|
|
|
19
|
-
The browser client ([packages/admin/src/shared/ws-client.ts](../../../packages/admin/src/shared/ws-client.ts)) runs a 15 s self-check timer (`startLiveness`) that catches three half-dead states: socket stuck in `CONNECTING` >10 s, `CLOSED` without `onclose` firing, and
|
|
19
|
+
The browser client ([packages/admin/src/shared/ws-client.ts](../../../packages/admin/src/shared/ws-client.ts)) runs a 15 s self-check timer (`startLiveness`) that catches three half-dead states: socket stuck in `CONNECTING` >10 s, `CLOSED` without `onclose` firing, and a **zombie-OPEN** socket — `readyState === OPEN` but the underlying TCP is dead (laptop sleep, NAT/proxy idle timeout; the server terminated its side but the FIN never arrived). Zombies are proven by a missed round-trip: every tick sends an app-level `__ping__` which the server answers with `__pong__`, so a healthy link sees inbound traffic newer than the previous probe on every tick; **2 consecutive probes with no inbound reply** → force-close so the exponential-backoff reconnect (1 s → 30 s cap) runs. Miss-counting is probe-relative, not wall-clock, so background-tab timer throttling doesn't misread a healthy-but-idle link as dead. (An earlier `bufferedAmount`-drain heuristic was removed — a ~20-byte probe is handed to the kernel instantly even when the peer is gone, so it never detected anything; the kernel keeps retransmitting for ~15 min before `onclose` fires.)
|
|
20
|
+
|
|
21
|
+
Two OS/browser signals supplement the timer:
|
|
22
|
+
|
|
23
|
+
- **`online` event** → `reconnectIfStale(0)`: the NIC was provably down, so the socket is torn down unconditionally (a stalled `CONNECTING` socket included).
|
|
24
|
+
- **`visibilitychange`** → `reconnectIfStale(LINK_STALE_MS)` (45 s), double-gated: the tab must have been **hidden ≥ 5 min** AND inbound traffic must actually be stale. Ungated visibility/focus probes were removed once before — inside iframes (code-server preview, embedded admin) they fire constantly and tore down healthy connections in an endless loop. The hidden-duration gate keeps that fix (iframe flapping hides the tab for milliseconds, never minutes) while restoring coverage for wake-from-sleep on a zombie link where no `online` event ever fires; the staleness gate means a healthy connection (background tabs keep receiving WS traffic fine) sails through untouched.
|
|
20
25
|
|
|
21
26
|
**Auth expiry**: when the WS handshake itself is rejected (close before `onopen` — `verifyClient` returns 401 on an expired JWT cookie, but the browser WS API hides the HTTP status), the client probes `/api/auth/check`. On 401 it stops reconnecting and emits `_auth_expired`; the admin page listens and swaps to the login screen. Any other probe outcome (server restarting, network blip) falls through to normal backoff.
|
|
22
27
|
|
|
23
28
|
After a successful `_connected` event, all subscribers re-issue their session-resume messages: `subscribe` (chat / agent state) and `terminal:reattach` (PTY pool). Both are idempotent on the server.
|
|
24
29
|
|
|
30
|
+
### Chat delivery: ack / resend / dedup
|
|
31
|
+
|
|
32
|
+
Liveness probes bound how long a zombie lives, but a chat sent *into* the zombie window would still vanish silently (`ws.send()` on a zombie-OPEN socket reports nothing). Chat is the one message class that must survive this, so it rides a dedicated ack/resend protocol; everything else stays fire-and-forget.
|
|
33
|
+
|
|
34
|
+
- **Client** ([ws-client.ts](../../../packages/admin/src/shared/ws-client.ts)): every chat carries a client-generated `clientMsgId` and enters a pending-ack table (capacity 100 — past that the oldest entry is failed visibly) before transmission. Transmission is gated on freshness: if inbound has been silent > 30 s, the socket is torn down first and the chat rides the reconnect instead of being trusted to a suspect link. **5 s without `chat:ack`** → force-close, and the `onopen` flush retransmits every unacked entry in insertion order. After **3 transmit attempts** the client stops tearing the link down on that chat's behalf (rapid network flaps would otherwise turn one chat into endless connection churn) but the entry stays pending and still rides every flush. A **2 min wall-clock deadline** per entry is the final arbiter: every chat terminates either in an ack or in a visible `_chat_send_failed` (red "send failed" badge on the bubble + its empty placeholder converged) — no infinite lurking.
|
|
35
|
+
- **Server** ([handler.ts](../../../packages/server/src/ws/handler.ts)): ack semantics = *the message is in the session log*. Each `handleChat` branch calls `rememberChat` **synchronously right after `appendUserMessage`** (no `await` in between — an await there opens a double-append window against a racing resend), then replies `{type:'chat:ack', clientMsgId}` once enqueueing is done. Resends are deduped against a handler-scoped FIFO of the last 500 acked ids (handler-scoped because a resend arrives on a *new* connection): a duplicate is re-acked without re-appending. At-least-once resend + server dedup ≈ exactly-once in the session log.
|
|
36
|
+
- **Indicator**: the connection light is tri-state (`fresh` / `stale` / `down`, [use-websocket.ts](../../../packages/admin/src/shared/use-websocket.ts)) driven by inbound-traffic age rather than last-known socket state — `stale` (amber) at > 45 s of silence (3× probe interval), `down` (red) on close. A zombie link can no longer sit green.
|
|
37
|
+
- **Known boundary (accepted)**: the dedup FIFO is in-memory. If the server restarts inside the narrow window between "ack sent but lost in flight" and the client's resend, the new process re-appends the message and the agent runs it twice. The overlap is seconds-rare, and persisting the table would put a synchronous disk write on the chat hot path — not worth it unless observed in practice.
|
|
38
|
+
|
|
25
39
|
## Client → Server
|
|
26
40
|
|
|
27
41
|
Source: [handler.ts](../../../packages/server/src/ws/handler.ts) — top-level `switch (msg.type)` in the connection handler.
|
|
@@ -29,11 +43,13 @@ Source: [handler.ts](../../../packages/server/src/ws/handler.ts) — top-level `
|
|
|
29
43
|
| Type | Purpose |
|
|
30
44
|
|---|---|
|
|
31
45
|
| `subscribe` | Subscribe to a session (load history, re-attach detached) |
|
|
32
|
-
| `chat` | Send a user message (queued when the agent is busy) |
|
|
46
|
+
| `chat` | Send a user message (queued when the agent is busy); acked per `clientMsgId` — see [Chat delivery](#chat-delivery-ack--resend--dedup) |
|
|
47
|
+
| `__ping__` | Application-level liveness probe; server replies `__pong__` |
|
|
33
48
|
| `chat:stop` | Hard-abort the current generation (ends the turn, no re-run) → `stopUserSession` |
|
|
34
49
|
| `chat:interrupt` | Interrupt the in-flight turn now (aborts a command mid-run); the server then folds any queued messages into one follow-up turn → `interruptSession`. Admin chat esc maps to this. A compacting session cancels the compact instead (same as `chat:stop`). |
|
|
35
50
|
| `session:clear` | Non-destructive /session new: save the current, detach, create fresh (handled inline) |
|
|
36
51
|
| `session:delete` | Delete session files + cascade-delete descendants in SQLite (handled inline) |
|
|
52
|
+
| `exchange:delete` | Delete one exchange (a user turn + its responses): **soft-delete** in the UI log (`deleted: true` markers, kept visible/greyed) + **physical-delete** the whole turn from `rawMessages` (LLM context) → `deleteExchange`. Fields: `userOrdinal` (0-based index among *main-conversation* user turns, i.e. excluding `taskId` sub-agent messages — matches the admin's `isMainConversationMessage` count), optional `sessionId` / `projectId`. Rejected (→ `error`) while the session is running or compacting. See [session.md](session.md#exchange-deletion-soft-ui--hard-raw). |
|
|
37
53
|
| `command:<name>` | Route through shared `dispatchCommand` (see [command.md](command.md)); `/session compact` handled inline for UI callbacks |
|
|
38
54
|
| `terminal:start` | Spawn a new PTY |
|
|
39
55
|
| `terminal:input` | Send keystrokes |
|
|
@@ -44,6 +60,7 @@ Source: [handler.ts](../../../packages/server/src/ws/handler.ts) — top-level `
|
|
|
44
60
|
Optional `chat` fields:
|
|
45
61
|
- `images`: `Array<{data: base64, mimeType}>` — multimodal
|
|
46
62
|
- `agentId`: specify the agent this session should use
|
|
63
|
+
- `clientMsgId`: client-generated id for the ack/resend/dedup protocol (admin always sends it)
|
|
47
64
|
|
|
48
65
|
## Server → Client
|
|
49
66
|
|
|
@@ -72,13 +89,21 @@ Source: [event-processor.ts:48-97](../../../packages/server/src/ws/event-process
|
|
|
72
89
|
| Type | Source | Purpose |
|
|
73
90
|
|---|---|---|
|
|
74
91
|
| `state:snapshot` | handler.ts on connect | Initial state (agents, messages, sessionId) |
|
|
92
|
+
| `chat:ack` | `handleChat` | Chat with `clientMsgId` is persisted in the session log — releases the client's pending-ack entry (see [Chat delivery](#chat-delivery-ack--resend--dedup)) |
|
|
93
|
+
| `__pong__` | `__ping__` handler | Reply to the client's application-level liveness probe |
|
|
75
94
|
| `chat:queued` | `sendUserMessage` returning queued | User-message-queued notification |
|
|
76
95
|
| `file:changed` | WorkspaceWatcher · GitDirWatcher · `routes/git.ts` | File change notification (path + action). Three sources: (1) **WorkspaceWatcher** — recursive workspace watch, deliberately excludes `.git`; (2) **`routes/git.ts`** — every git mutation route re-broadcasts `path:'.git'` itself (the recursive watcher ignores `.git`); (3) **GitDirWatcher** — a non-recursive `.git`-dir watch for command-line git ops, *plus* a degraded "watch the workspace root for `.git` appearing" phase that fires `path:'.git'` on a terminal `git init`/`clone` so the Source Control entry auto-surfaces. See [source-control.md](../requirements/source-control.md#auto-refresh-no-polling). |
|
|
77
96
|
| `terminal:ready` / `terminal:output` / `terminal:exit` / `terminal:reattached` | TerminalManager | PTY output |
|
|
78
97
|
| `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). |
|
|
98
|
+
| `session:switched` | handler.ts (this client only) | The server rebound this connection to a different session — see [switchTo rebind](#switchto-rebind--sessionswitched) |
|
|
99
|
+
| `goal:changed` | `writeGoalState` (`agents/goal-mode.ts`, broadcast to all clients) | Goal-mode state transition: `{goalSessionId, workerSessionId, status, round, maxRounds}`. Emitted on every goal state write (every transition routes through `writeGoalState`, so the push can never be forgotten) plus the goal-session-delete dissolve path. **No workspace marker** — the admin re-fetches through `GET /api/sessions/goal` under its active project, which naturally filters cross-workspace events. Drives the goal banner, worker input lock, and 🎯 badge refresh. See [goal-mode.md](goal-mode.md#admin-surface--ws). |
|
|
79
100
|
| `session:cleared` | session:clear handler | /session new complete |
|
|
101
|
+
| `session:deleted` | session:delete handler (this client only) | Session delete complete — `{sessionId}` |
|
|
102
|
+
| `chat:stopped` | `chat:stop` / `chat:interrupt` handlers (this client only) | Stop/interrupt acknowledged — `{sessionId}` |
|
|
80
103
|
| `session:compacted` | compact handler | Compaction complete |
|
|
81
104
|
| `compact:started` / `compact:summarizing` / `compact:done` | compact handler | Compaction progress |
|
|
105
|
+
| `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). |
|
|
106
|
+
| `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). |
|
|
82
107
|
|
|
83
108
|
## WS Handler as a thin session client
|
|
84
109
|
|
|
@@ -109,6 +134,12 @@ UI state (messageLog / streamBuffer / turnToolCalls / tokens) belongs to Session
|
|
|
109
134
|
- `command:session` with `compact` verb — calls `sm.compactSession(sid, { onProgress })` directly for real-time progress feedback
|
|
110
135
|
- All other `command:*` — builds a shared `CommandContext` and routes through `dispatchCommand()` (see [command.md](command.md))
|
|
111
136
|
|
|
137
|
+
### switchTo rebind & `session:switched`
|
|
138
|
+
|
|
139
|
+
When a command result carries `switchTo` (e.g. `/new`, `/goal create`, `/goal resume`), 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 (e.g. the goal agent's intake greeting right after `/goal create`) would never reach the connection — the listener would still point at the old id.
|
|
140
|
+
|
|
141
|
+
The same mechanics run on the **goal-routing overlay** in `handleChat`: a chat aimed at a goal-bound worker is diverted to its goal session (`resolveGoalRoute`), 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`).
|
|
142
|
+
|
|
112
143
|
### Message flow
|
|
113
144
|
|
|
114
145
|
1. `chat` → check if the session exists → `SessionManager.createSession()` or `sendUserMessage()`
|
|
@@ -19,15 +19,16 @@ 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, vision, thinking (
|
|
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
23
|
| `deepseek` | `DeepSeekAgent` | `deepseek-agent.ts` | OpenAI-compatible, thinking, no vision |
|
|
24
|
-
| `minimax` | `MiniMaxAgent` | `minimax-agent.ts` | Anthropic Messages API (x-api-key), thinking always-on (manual budget_tokens), prompt caching (5m, M2.7 doesn't report cache_creation_input_tokens)
|
|
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
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
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
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
28
|
| `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). |
|
|
29
29
|
| `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'`. |
|
|
30
|
-
| `
|
|
30
|
+
| `mimo-token-plan-china` | `AnthropicAgent` | (reuses `anthropic-agent.ts`) | Xiaomi MiMo token plan (China) — Anthropic-compatible gateway at `token-plan-cn.xiaomimimo.com/anthropic`; no dedicated class, just a default endpoint in `model-runtime.ts` |
|
|
31
|
+
| `aws-bedrock-mantle` | `MantleAgent` | `mantle-agent.ts` | OpenAI 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, no budget_tokens), `text.verbosity`. 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. |
|
|
31
32
|
|
|
32
33
|
---
|
|
33
34
|
|
|
@@ -148,6 +149,7 @@ models:
|
|
|
148
149
|
- id: my-model-v1
|
|
149
150
|
displayName: My Model V1
|
|
150
151
|
maxOutputTokens: 16384
|
|
152
|
+
contextWindow: 200000 # official context window in tokens (optional)
|
|
151
153
|
capabilities:
|
|
152
154
|
image: true # supports image input
|
|
153
155
|
video: false # no video understanding
|
|
@@ -168,6 +170,8 @@ models:
|
|
|
168
170
|
- `capabilities.thinking.effortPresets` → renders the Thinking effort dropdown
|
|
169
171
|
- Omit either block → the UI hides that control for this model
|
|
170
172
|
|
|
173
|
+
**`contextWindow` feeds the session context budget** (compaction threshold, token ring). Resolution priority: agent.yaml `context.maxTokens` > registry `contextWindow` > global default 200K (`config.model.maxContextTokens`). Resolved by `resolveContextWindow()` in [config.ts](../../../packages/server/src/config.ts), consumed at two points with the same priority chain: `buildModelRuntime` (session-agent-builder.ts, live sessions) and the cold-session `getContextConfig()` path (session-manager.ts, display for sessions not in memory). Declare the official number on every model entry — without it, small-window models (e.g. 128K) silently use the 200K default and 1M models compact far too early.
|
|
174
|
+
|
|
171
175
|
Shape source: [packages/server/templates/models/aws-bedrock-claude-invoke.yaml](../../../packages/server/templates/models/aws-bedrock-claude-invoke.yaml) and [design/storage.md#model-registry-format](../design/storage.md#model-registry-format).
|
|
172
176
|
|
|
173
177
|
### 3. Wire the dispatcher
|
|
@@ -348,16 +352,20 @@ def png(w, h, rgb):
|
|
|
348
352
|
print(base64.b64encode(png(64, 64, [255, 0, 0])).decode())
|
|
349
353
|
```
|
|
350
354
|
|
|
351
|
-
### Bedrock-Mantle
|
|
355
|
+
### Bedrock-Mantle returned streaming snapshots as separate output items (5.5-era, workarounds removed)
|
|
356
|
+
|
|
357
|
+
**Historical** — this affected GPT-5.5 on Mantle; 7 live probes across 5.6-sol and 5.4 showed zero recurrence, so the two `MantleAgent` workarounds below were removed with the GPT-5.6 rollout. Kept here as diagnosis knowledge in case the quirk resurfaces on a future model.
|
|
358
|
+
|
|
359
|
+
`aws-bedrock-mantle` (GPT-5.x via Bedrock's OpenAI Responses surface) had a server-side quirk: with **reasoning enabled**, it returned the *streaming generation* of a single logical output as **multiple completed items**, each a growing superset of the last. Two faces of the same bug:
|
|
352
360
|
|
|
353
|
-
|
|
361
|
+
- **`message`**: emitted as `reasoning → message → reasoning → message …`, each later `message` repeating the prior as a growing superset → `MantleAgent` kept only the **last** message item.
|
|
362
|
+
- **`function_call`**: a **long-argument** tool call came back as `reasoning,fc,reasoning,fc,…` — N items, each carrying a growing **prefix snapshot** of the arguments JSON. Only the last was complete, valid JSON; the earlier N-1 were truncated mid-string (verified: a `draft` call returned 11 truncated snapshots + 1 complete, arguments length 461→…→2214). `MantleAgent` filtered on **JSON completeness** — a truncated fragment fails `JSON.parse` and got skipped, keeping genuine parallel tool_use (each `function_call` its own complete JSON) fully preserved.
|
|
354
363
|
|
|
355
|
-
-
|
|
356
|
-
- **`function_call`**: a **long-argument** tool call comes back as `reasoning,fc,reasoning,fc,…` — N items, each carrying a growing **prefix snapshot** of the arguments JSON. Only the last is complete, valid JSON; the earlier N-1 are truncated mid-string (verified: a `draft` call returned 11 truncated snapshots + 1 complete, arguments length 461→…→2214). `MantleAgent` filters on **JSON completeness** — `strictParseOrNull` returns null for a truncated fragment and the parse loop skips it. This is tool- and length-agnostic: a genuine parallel tool_use (each `function_call` its own complete JSON, e.g. 4 distinct `shell_exec` searches) is fully preserved; only truncated snapshots are dropped.
|
|
364
|
+
Distinguishing snapshot-spam from real parallel calls: snapshots are same-`name` with `reasoning,fc` **interleaved** and arguments forming a growing prefix chain; parallel calls are one `reasoning` then `fc,fc,fc` **consecutive** with independent complete arguments. Root cause was in Bedrock-Mantle, not halo — first-party OpenAI returns a single final item.
|
|
357
365
|
|
|
358
|
-
|
|
366
|
+
Diagnosis note (still applies): the session file's `rawMessages` stores arguments **after** parsing, so a truncated snapshot shows as `{}` — it looks like an "empty tool call", not a snapshot. To see the real shape you must capture Mantle's **raw response body** (temporary `console.warn` in the parse loop; `console.log` is dropped at the default `warn` log level).
|
|
359
367
|
|
|
360
|
-
|
|
368
|
+
The `MantleEmptyResponse` retry guard in session-manager is unrelated to this and stays — that's a separate transient fault (`status=completed` with an empty `output[]`).
|
|
361
369
|
|
|
362
370
|
---
|
|
363
371
|
|
package/bundled-docs/dev/api.md
CHANGED
|
@@ -64,8 +64,9 @@ Unified session log API — list + read session files across all agents.
|
|
|
64
64
|
| GET | `/api/sessions/logs/:id?projectId=` | Full session log (scans across agent dirs) |
|
|
65
65
|
| DELETE | `/api/sessions/logs/:id?projectId=` | Delete the session log |
|
|
66
66
|
| PATCH | `/api/sessions/logs/:id?projectId=` | Rename a session (admin-only) — updates the log file's title. Accepts any session id (root or sub-agent); the sidebar exposes the rename affordance on every row |
|
|
67
|
+
| GET | `/api/sessions/goal?projectId=` | Latest goal binding for the workspace (goal-mode banner / input-lock seed) — see [detail](#get-apisessionsgoalprojectidabs) |
|
|
67
68
|
|
|
68
|
-
The list endpoint returns flat metadata (id / agentId / agentName / title / timestamps / messageCount / parentSessionId / stoppedAt / contextTokens / totalOutputTokens). The frontend builds the tree from `parentSessionId
|
|
69
|
+
The list endpoint returns flat metadata (id / agentId / agentName / title / timestamps / messageCount / parentSessionId / stoppedAt / contextTokens / totalOutputTokens / goalSessionId). The frontend builds the tree from `parentSessionId`; `goalSessionId` (non-null on a goal-bound worker row) drives the 🎯 badge.
|
|
69
70
|
|
|
70
71
|
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.
|
|
71
72
|
|
|
@@ -324,6 +325,10 @@ File: `packages/server/src/routes/evolution.ts`. Surfaces the global `evolution_
|
|
|
324
325
|
| DELETE | `/api/evolution/runs/:id` | Delete a finished run: its on-disk artifacts (run dir + archive zip) **and** the DB row. Rejected with 409 for in-flight states (`pending` / `running` / `approved`) so a live wrapper / queued apply isn't pulled out from under. Broadcasts `evolution:run_changed` with `kind:'deleted'`. |
|
|
325
326
|
| GET | `/api/evolution/applies` | List apply rows (used for status badges). |
|
|
326
327
|
|
|
328
|
+
## AgentCore adapter (runtime-mode only)
|
|
329
|
+
|
|
330
|
+
File: `packages/server/src/routes/agentcore.ts`. Mounted **only** when `HALO_RUNTIME_MODE=agentcore` (see [design/agentcore.md](../design/agentcore.md)): `GET /ping` (health, `HealthyBusy` while any agent session runs), `POST /invocations` (prompt in / reply out), and streaming `WS /ws`. Not present in normal server mode.
|
|
331
|
+
|
|
327
332
|
---
|
|
328
333
|
|
|
329
334
|
## Request / Response schemas
|
|
@@ -527,6 +532,25 @@ Returns the full session file. If only `rawMessages` is present (sub-agent sessi
|
|
|
527
532
|
}
|
|
528
533
|
```
|
|
529
534
|
|
|
535
|
+
### GET `/api/sessions/goal?projectId=<abs>`
|
|
536
|
+
|
|
537
|
+
Source: `packages/server/src/routes/sessions.ts` (`findLatestGoal` in `agents/goal-mode.ts`)
|
|
538
|
+
|
|
539
|
+
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).
|
|
540
|
+
|
|
541
|
+
```json
|
|
542
|
+
// 200
|
|
543
|
+
{
|
|
544
|
+
"goal": {
|
|
545
|
+
"goalSessionId": "goal_mabc123",
|
|
546
|
+
"workerSessionId": "sid_abc",
|
|
547
|
+
"status": "running", // intake | running | paused | halted | done
|
|
548
|
+
"round": 3,
|
|
549
|
+
"maxRounds": 50
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
```
|
|
553
|
+
|
|
530
554
|
### GET `/api/settings/schema?projectId=<abs>`
|
|
531
555
|
|
|
532
556
|
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.
|
|
@@ -634,6 +658,7 @@ Full WS protocol in [design/ws.md](../design/ws.md). The four high-traffic clien
|
|
|
634
658
|
"projectId": "/abs/ws",
|
|
635
659
|
"message": "hello",
|
|
636
660
|
"agentId": "default", // optional override
|
|
661
|
+
"clientMsgId": "abc123", // optional — ack/resend dedup id
|
|
637
662
|
"images": [ { "data": "<base64>", "mimeType": "image/png" } ] // optional
|
|
638
663
|
}
|
|
639
664
|
```
|
|
@@ -643,6 +668,7 @@ Server behaviour ([handler.ts `handleChat`](../../../packages/server/src/ws/hand
|
|
|
643
668
|
- Persists pasted images to `<ws>/.halo/web/inbound/<date>/`
|
|
644
669
|
- If the model does not support image input (`capabilities.image: false`), images are filtered out and a text notice is appended instead of sending to the API
|
|
645
670
|
- Queues if busy/compacting; otherwise runs the agent turn
|
|
671
|
+
- When `clientMsgId` is present, replies `{ "type": "chat:ack", "clientMsgId" }` once the message is appended to the session log; resends with an already-acked id are re-acked without re-appending (dedup — see [design/ws.md](../design/ws.md#chat-delivery-ack--resend--dedup))
|
|
646
672
|
- Streams events back via `message`, `tool_call`, `usage`, etc.
|
|
647
673
|
|
|
648
674
|
### `subscribe` (C→S)
|
|
@@ -32,13 +32,9 @@ pnpm --filter @turmind/halo-server build
|
|
|
32
32
|
pnpm --filter @turmind/halo-admin build # next build + copy-monaco; never a bare next build (Monaco would 404)
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
## 2.
|
|
35
|
+
## 2. Runtime data locations
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
mkdir -p /path/to/halo/data
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
SQLite databases are created automatically under `data/` on first start.
|
|
37
|
+
No directory needs to be created by hand. SQLite databases are created automatically on first use: per-workspace state at `<workspace>/.halo/halo.db`, plus global queues at `~/.halo/global/evo.db` and `~/.halo/global/cron.db`.
|
|
42
38
|
|
|
43
39
|
## 3. Run `halo setup`
|
|
44
40
|
|
|
@@ -154,8 +150,8 @@ This installs the `halo` binary on `$PATH`. Subcommands available:
|
|
|
154
150
|
|
|
155
151
|
### Release checklist (before `npm publish`)
|
|
156
152
|
|
|
157
|
-
1. **Bump version** in
|
|
158
|
-
2. **Update `CHANGELOG.md`**: rename `[Unreleased]` → `[x.y.z] - YYYY-MM-DD`, add a fresh empty `[Unreleased]` section above it.
|
|
153
|
+
1. **Bump version** in the five workspace `package.json` files (`packages/{cli,server,core,admin,desktop}/package.json`) — the root `package.json` has no version field.
|
|
154
|
+
2. **Update `CHANGELOG.md`**: rename `[Unreleased]` → `[x.y.z] - YYYY-MM-DD`, add a fresh empty `[Unreleased]` section above it, and roll the link references at the bottom (add `[x.y.z]: compare/v<prev>...vx.y.z`, repoint `[Unreleased]` to `compare/vx.y.z...HEAD`).
|
|
159
155
|
3. **Bump `TEMPLATE_VERSION`** in `packages/server/src/init.ts` if any file under `templates/` was touched.
|
|
160
156
|
4. **Build admin**: `pnpm --filter @turmind/halo-admin build` — verify `admin/out/monaco/vs/loader.js` exists.
|
|
161
157
|
5. Commit, tag `vx.y.z`, push, then publish.
|
|
@@ -393,6 +393,26 @@ Still good practice: build server + admin **before** `pnpm dist:arm64`.
|
|
|
393
393
|
The real per-target artifacts are `Halo-0.1.5.dmg` (x64) and `Halo Setup
|
|
394
394
|
0.1.5.exe` (win); only trust the arm64 dmg from an actual `dist:arm64` run.
|
|
395
395
|
|
|
396
|
+
- **npm packages can hide ~200-char relative paths that break every Windows
|
|
397
|
+
upgrade (MAX_PATH) — jimp ships its jest snapshots.** `@jimp/*` publishes
|
|
398
|
+
`src/__image_snapshots__/*.png` with ~150-char *file names*; staged under
|
|
399
|
+
`resources/{server,cli}-runtime/node_modules/`, the longest INSTDIR-relative
|
|
400
|
+
path hit 200 chars. Install works (`C:\Users\<u>\AppData\Local\Programs\Halo\`
|
|
401
|
+
+ 200 stays under 260), but **upgrade doesn't**: the old uninstaller's
|
|
402
|
+
`atomicRMDir` renames every file into `%TEMP%\ns<x>.tmp\old-install\<relpath>`
|
|
403
|
+
(a *longer* prefix, ≈253 + username), crosses MAX_PATH, aborts → non-zero exit
|
|
404
|
+
→ the installer shows the misleading "Halo cannot be closed. Please close it
|
|
405
|
+
manually" prompt (`appCannotBeClosed` is its catch-all for *any* uninstaller
|
|
406
|
+
failure — no process check involved, which is why it fires on a fresh boot)
|
|
407
|
+
then "Failed to uninstall old application files". Users "fixed" it by deleting
|
|
408
|
+
the install dir — that removes `uninstall.exe`, so the installer silently
|
|
409
|
+
skips the uninstall step. Fixed in `stage-runtime.mjs pruneImageSnapshots()`
|
|
410
|
+
(full + fast paths — fast reuses old trees the deps fingerprint doesn't
|
|
411
|
+
re-check): drops all `@jimp/*/src/__image_snapshots__` from both runtimes.
|
|
412
|
+
Rule of thumb: after adding any dependency, check the staged tree with
|
|
413
|
+
`find resources/{server,cli}-runtime -type f | awk '{print length($0)}' |
|
|
414
|
+
sort -rn | head -1` — keep the longest relative path well under ~200.
|
|
415
|
+
|
|
396
416
|
## Verifying a build
|
|
397
417
|
|
|
398
418
|
```bash
|
package/bundled-docs/dev/env.md
CHANGED
|
@@ -67,7 +67,7 @@ Your deployment scripts should also prepend this line.
|
|
|
67
67
|
|
|
68
68
|
## Runtime directories
|
|
69
69
|
|
|
70
|
-
- SQLite:
|
|
70
|
+
- SQLite: `<workspace>/.halo/halo.db` (per-workspace, auto-created on first use) + global queues `~/.halo/global/evo.db` and `~/.halo/global/cron.db`
|
|
71
71
|
- Session files: `.halo/sessions/{agentId}/{sessionId}.json`
|
|
72
72
|
- Global config: `~/.halo/global/`
|
|
73
73
|
- Per-project config: `<workspace>/.halo/`
|
|
@@ -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`, `__evo_agent__`, `__score__`, `__apply_agent__`), built-in skill ids (`agent`, `skill`, `workspace`, `cron`, `acp`, `send-file`, `self`, `aws-knowledge`, `nova-web-search`, `halo`).
|
|
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`, `goal`, `__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.
|
|
@@ -101,7 +101,7 @@ Source: `packages/server/src/config.ts`
|
|
|
101
101
|
| `HALO_CORS_ORIGINS` | empty (reflect any origin) | `config.yaml server.cors_origins` | CORS allowlist (comma-separated). Empty = reflect any incoming Origin so credentials work cross-origin. Set explicit list to enforce strict CORS. |
|
|
102
102
|
| `HALO_FRONTEND_DIR` | `packages/admin/out` | — | Static frontend dir (resolved as absolute path from project root) |
|
|
103
103
|
| `HALO_MAX_CONTEXT_TOKENS` | `200000` | — | Model max context |
|
|
104
|
-
| `HALO_SHELL_TIMEOUT` | `
|
|
104
|
+
| `HALO_SHELL_TIMEOUT` | `600000` | `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
106
|
| `HALO_MODEL_TIMEOUT` | `1800000` | `config.yaml timeout.model_request` | Per model-call wall-clock cap (ms, 30 min). 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 |
|
|
@@ -109,6 +109,10 @@ Source: `packages/server/src/config.ts`
|
|
|
109
109
|
| `HALO_MAX_CACHED_SESSIONS` | `50` | — | In-memory session cache |
|
|
110
110
|
| `HALO_LOG_MAX_SIZE` | `10485760` | `config.yaml logging.max_file_size` | Log file size cap (10 MB) |
|
|
111
111
|
| `HALO_LOG_MAX_FILES` | `5` | `config.yaml logging.max_files` | Retained rotated logs |
|
|
112
|
+
| `HALO_LOG_LEVEL` | `warn` | `settings.yaml general.logging.level` | Log level: debug \| info \| warn \| error |
|
|
113
|
+
| `HALO_JWT_SECRET` | (none) | `config.yaml server.jwt_secret` | JWT signing secret (generated by `halo setup` if missing; stripped from child-process env) |
|
|
114
|
+
| `HALO_RUNTIME_MODE` | (none) | — | `agentcore` runs the server as an Amazon Bedrock AgentCore Runtime container (see [design/agentcore.md](../design/agentcore.md)) |
|
|
115
|
+
| `HALO_WORKSPACE` | `process.cwd()` | — | Workspace root in agentcore mode |
|
|
112
116
|
|
|
113
117
|
settings.yaml only (no env override):
|
|
114
118
|
- `general.session.max_queue_size` (default 256)
|
|
@@ -119,8 +123,9 @@ settings.yaml only (no env override):
|
|
|
119
123
|
- `general.compact.max_message_slice` (default 800) — local compaction per-message cap
|
|
120
124
|
- `general.compact.summarize_timeout_sec` (default 300) — self-compact timeout
|
|
121
125
|
- `general.server.trust_proxy` (default `false`) — trust `x-forwarded-for` for client IP resolution, scope: global (enable only behind a reverse proxy you control)
|
|
122
|
-
- `general.sandbox.hidden_dirs` (default `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh`) — bwrap tmpfs overlays, scope: global
|
|
123
|
-
- `general.sandbox.
|
|
126
|
+
- `general.sandbox.hidden_dirs` (default `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs`) — bwrap tmpfs overlays, scope: global
|
|
127
|
+
- `general.sandbox.writable_dirs` (default empty) — dirs bind-mounted read-write inside the bwrap sandbox, for external CLIs that keep local state (e.g. `~/.kiro`); not applied to readonly sessions, scope: global
|
|
128
|
+
- `general.sandbox.hidden_files` (default `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron}.db` + their `-wal`/`-shm` files) — bwrap /dev/null binds, scope: global
|
|
124
129
|
- `general.logging.level` (default `warn`) — log level: debug | info | warn | error
|
|
125
130
|
|
|
126
131
|
`<provider-id>.secrets.*` (server-only, hard-rejected by `{{}}` substitution; declared in `models/<provider-id>.yaml` `secrets:`):
|
|
@@ -68,7 +68,7 @@ Run a shell command. Full shell access.
|
|
|
68
68
|
|---|---|---|---|
|
|
69
69
|
| command | string | yes | Shell command |
|
|
70
70
|
|
|
71
|
-
Timeout
|
|
71
|
+
Timeout 600 s (`HALO_SHELL_TIMEOUT`). Max output 5 MB. The tool description surfaces the effective timeout to the agent and advises backgrounding (`nohup … &` + log polling) for longer tasks.
|
|
72
72
|
|
|
73
73
|
**Windows output encoding.** The Windows path (`sandbox.ts`) prepends `chcp 65001` so cmd built-ins (`echo`, …) emit UTF-8, then captures raw bytes (`encoding: 'buffer'`) and decodes them strict-UTF-8 with a GBK fallback. This is because native Win32 console tools (`ipconfig`, `systeminfo`, …) ignore `chcp` and still emit the OEM code page (GBK/CP936 on zh-CN); decoding such bytes as UTF-8 produced mojibake. The strict-UTF-8 attempt passes genuine UTF-8 through untouched and only falls back to GBK when the bytes aren't valid UTF-8 (GBK double-byte sequences almost always aren't). mac/Linux are unaffected (UTF-8 throughout).
|
|
74
74
|
|
|
@@ -137,8 +137,9 @@ Configured in `settings.yaml` under `general.sandbox`:
|
|
|
137
137
|
|
|
138
138
|
| Setting | Default | Method |
|
|
139
139
|
|---|---|---|
|
|
140
|
-
| `hidden_dirs` | `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh` | `--tmpfs` overlay (empty directory) |
|
|
141
|
-
| `hidden_files` | `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc` | `--ro-bind /dev/null` (empty file) |
|
|
140
|
+
| `hidden_dirs` | `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs` | `--tmpfs` overlay (empty directory) |
|
|
141
|
+
| `hidden_files` | `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron}.db` + their `-wal`/`-shm` files | `--ro-bind /dev/null` (empty file) |
|
|
142
|
+
| `writable_dirs` | (empty) | `--bind` read-write — for external CLIs that keep local state (e.g. `~/.kiro`); not applied to readonly sessions |
|
|
142
143
|
|
|
143
144
|
Changes take effect immediately — `config.ts` reads settings.yaml via an mtime-watched lazy cache, so the next `shell_exec` reads the latest values. These keys are `globalOnly` in the schema — a workspace `settings.yaml` cannot override them, since they define the security boundary agents run inside.
|
|
144
145
|
|
|
@@ -286,6 +287,61 @@ Get an agent's full details: AGENT.md, model config, tools, skills. Use it befor
|
|
|
286
287
|
|
|
287
288
|
Returns: full SKILL.md content (body + resource files list). For progressive disclosure — the system prompt only contains skill metadata (name + description); the agent calls this tool on demand.
|
|
288
289
|
|
|
290
|
+
## Goal tools
|
|
291
|
+
|
|
292
|
+
Injected **only** for the built-in `goal` agent (G) — `session-agent-builder` swaps in this set (`buildGoalTools`, `agents/goal-mode.ts`) instead of the standard session bundle when the session's agent is `GOAL_AGENT_ID`. Every callback re-reads goal state from the workspace db (never a cached copy), so a halt / pause / clear that landed while G was mid-turn is enforced on its next tool call. See [design/goal-mode.md](../design/goal-mode.md).
|
|
293
|
+
|
|
294
|
+
### goal_context
|
|
295
|
+
|
|
296
|
+
Load the goal binding: worker session id, goal dir, `GOAL_SPEC.md` path, caps, status, round, counters (`delegatedCount`/cap, `noProgress`, `startedAt`/`elapsed`). No arguments. Call first in every conversation and after any restart nudge.
|
|
297
|
+
|
|
298
|
+
During `intake` the result also embeds `workerRecent` — the worker's last 20 non-empty user/assistant messages (transcript `role=system` noise skipped, 400 chars each, 8K total budget applied newest-first) plus `workerMessageCount` — so G seeds the intake conversation without parsing transcript files. Running goals don't embed it (G works off delivered round reports; embedding on every call would burn tokens).
|
|
299
|
+
|
|
300
|
+
### goal_attach
|
|
301
|
+
|
|
302
|
+
The hinge from intake conversation to running loop. Preconditions: status `intake` and `GOAL_SPEC.md` written to the goal dir (missing → error naming the expected path). Stamps the spec sha256, records the worker's output-token baseline, applies cap overrides, flips to `running`, and dispatches the kickoff to the worker under a `[Goal work order · round 1/N]` header. Call exactly once, only after the user confirms the contract.
|
|
303
|
+
|
|
304
|
+
| Arg | Type | Required | Description |
|
|
305
|
+
|---|---|---|---|
|
|
306
|
+
| `kickoff` | string | yes | Round-1 work order, sent verbatim (header prepended by the platform) |
|
|
307
|
+
| `caps` | object | no | Overrides pinned during intake: `max_rounds` / `max_hours` / `max_tokens`; omitted fields keep defaults (10 rounds / 4h / no token budget) |
|
|
308
|
+
| `decision_policy` | string | no | One-line record of what kinds of forks the user delegated |
|
|
309
|
+
|
|
310
|
+
### goal_decide
|
|
311
|
+
|
|
312
|
+
Record a delegated decision — a fork G answered on the user's behalf because spec + scene made the answer clear. Writes `decision-<n>.md` to the goal dir **before** the answer is relayed; counts against a cap of 5 per goal (cap reached → error telling G to park the question to the user). Only while `running`.
|
|
313
|
+
|
|
314
|
+
| Arg | Type | Required | Description |
|
|
315
|
+
|---|---|---|---|
|
|
316
|
+
| `question` | string | yes | The fork the worker raised |
|
|
317
|
+
| `decision` | string | yes | What G decided |
|
|
318
|
+
| `rationale` | string | no | Why the contract/scene supports it |
|
|
319
|
+
|
|
320
|
+
### goal_finish
|
|
321
|
+
|
|
322
|
+
Final acceptance: `running → done`, dissolves the binding (clears the worker's back-pointer; the chat surface returns to the worker). G then writes the final report as its reply — it flows to the user and must list every delegated decision.
|
|
323
|
+
|
|
324
|
+
| Arg | Type | Required | Description |
|
|
325
|
+
|---|---|---|---|
|
|
326
|
+
| `summary` | string | yes | One-line result recorded in the goal state |
|
|
327
|
+
|
|
328
|
+
### query_session (goal-scoped)
|
|
329
|
+
|
|
330
|
+
G's **lateral edge**: same name as the standard session tool, different implementation — only the bound worker is reachable, only while `running` (any other status → `lateral edge revoked`), and a `[Goal work order · round N/cap]` header is prepended in code. Halting a goal revokes this edge, which is what makes runaway impossible.
|
|
331
|
+
|
|
332
|
+
| Arg | Type | Required | Description |
|
|
333
|
+
|---|---|---|---|
|
|
334
|
+
| `session_id` | string | yes | Must be the bound worker session id |
|
|
335
|
+
| `message` | string | yes | The work order / relayed answer / steering update |
|
|
336
|
+
|
|
337
|
+
### get_session_output (goal-scoped)
|
|
338
|
+
|
|
339
|
+
Read the full latest-turn output of the worker or any session in the worker's subtree (evidence gathering — round reports are truncated at `limits.autoReportMax`). Scoped to the worker's tree; works regardless of goal status.
|
|
340
|
+
|
|
341
|
+
| Arg | Type | Required | Description |
|
|
342
|
+
|---|---|---|---|
|
|
343
|
+
| `session_id` | string | yes | Worker session id or a descendant (`worker>child`) id |
|
|
344
|
+
|
|
289
345
|
## Self-review tool
|
|
290
346
|
|
|
291
347
|
### draft
|
|
@@ -324,7 +380,7 @@ There is **no implicit default tool set**: `filterTools()` (in `agent-loader.ts`
|
|
|
324
380
|
|
|
325
381
|
| Config key | Default | Purpose |
|
|
326
382
|
|---|---|---|
|
|
327
|
-
| `timeout.shellExec` |
|
|
383
|
+
| `timeout.shellExec` | 600,000 ms | Shell command timeout |
|
|
328
384
|
| `timeout.webFetch` | 10,000 ms | HTTP timeout |
|
|
329
385
|
| `limits.shellOutputBuffer` | 5 MB | Shell output buffer |
|
|
330
386
|
| `limits.webFetchMaxBody` | 50 KB | web_fetch body cap |
|
|
@@ -97,7 +97,7 @@ halo cli -w /path/to/review-ws "review the diff" --format json | jq '.text'
|
|
|
97
97
|
# Pipe content in
|
|
98
98
|
git diff HEAD~1 | halo cli -w /path/to/review-ws "review this diff"
|
|
99
99
|
|
|
100
|
-
# Exit code: 0 = success, 1 = error, 130 = SIGINT
|
|
100
|
+
# Exit code: 0 = success, 1 = error, 130 = SIGINT, 143 = SIGTERM (graceful: session repaired + flushed before exit)
|
|
101
101
|
halo cli "check for issues" && echo "OK" || echo "FAILED"
|
|
102
102
|
```
|
|
103
103
|
|
|
@@ -130,6 +130,7 @@ Multi-turn conversation. Supports all standard Halo slash commands:
|
|
|
130
130
|
| `/cron <verb>` | Manage scheduled agent runs: `create` / `list` / `update` / `enable` / `disable` / `delete` |
|
|
131
131
|
| `/acp <verb>` | Ask other agents over ACP (`kiro <q>` / `claude <q>`) and manage `ask-*` bindings (`add` / `list` / `remove`) |
|
|
132
132
|
| `/evo [hint]` | Queue a self-evolution run on this session (full access only) |
|
|
133
|
+
| `/goal <verb>` | Goal Mode: `create` / `status` / `pause` / `resume` / `clear` (full access only) |
|
|
133
134
|
| `/quit` | Exit |
|
|
134
135
|
|
|
135
136
|
TUI-only client commands (handled locally, also visible in the completion popup):
|
|
@@ -192,7 +193,7 @@ Attach files or images to your message with `@file` and `@image`, or pull a dire
|
|
|
192
193
|
```
|
|
193
194
|
packages/cli/
|
|
194
195
|
src/
|
|
195
|
-
index.ts — Entry: subcommand dispatch (tui/cli/agents/sessions/server/setup/acp), SIGINT handling
|
|
196
|
+
index.ts — Entry: subcommand dispatch (tui/cli/agents/sessions/server/setup/acp), SIGINT/SIGTERM handling
|
|
196
197
|
harness.ts — Shared agent harness wrapping SessionManager
|
|
197
198
|
cli.ts — Non-interactive: stdin/args → agent → stdout → exit
|
|
198
199
|
tui.tsx — Interactive entry: mounts the ink app
|
|
@@ -17,6 +17,12 @@ The primary surface for talking to an agent.
|
|
|
17
17
|
- Sub-agent messages carry the agent-name label (e.g. "Coder", "Researcher")
|
|
18
18
|
- Streaming text has a cursor animation
|
|
19
19
|
|
|
20
|
+
### User-message actions (Copy / Delete / Show)
|
|
21
|
+
Hover actions on the blue sticky user bubble — real user prompts only (sub-agent report / compact-summary callouts, though user-role, get no buttons):
|
|
22
|
+
- **Copy** — copies the prompt text to the clipboard
|
|
23
|
+
- **Show more/less** — the existing clamp toggle, shown when the text overflows
|
|
24
|
+
- **Delete** (confirm dialog) — removes the whole exchange (the user turn + all responses up to the next user turn) with **two-layer semantics**: the LLM context (`rawMessages`) drops the turn physically — the model never sees it again, freeing context; the UI keeps the messages, rendered greyed-out with a "deleted" tag, as an audit trail. No undo; a deleted exchange loses its Delete button. Rejected with an error toast while the agent is running or compacting. Root sessions only (sub-session logs don't offer Delete). If the turn was already compacted out of raw context, only the UI marking happens (silent degrade). Design details in [design/session.md](../design/session.md#exchange-deletion-soft-ui--hard-raw), protocol in [design/ws.md](../design/ws.md).
|
|
25
|
+
|
|
20
26
|
### Slash commands
|
|
21
27
|
|
|
22
28
|
The full command list is fetched from `GET /api/commands` per session and includes built-ins + skill commands. The following are always-present highlights for the chat surface:
|
|
@@ -31,6 +37,9 @@ The full command list is fetched from `GET /api/commands` per session and includ
|
|
|
31
37
|
|
|
32
38
|
See [requirements/command.md](command.md) for the full command surface.
|
|
33
39
|
|
|
40
|
+
### Goal-mode banner
|
|
41
|
+
When a goal is bound to the current session (see [command.md → `/goal`](command.md) / [design/goal-mode.md](../design/goal-mode.md)), a strip above the composer shows status (intake / running round N/max / paused / halted / done); a label click jumps to the goal session, a `Worker →` button jumps back to the worker. Terminal states (done/halted) are dismissible — dismissal persists per-project in `localStorage` so it survives a page refresh; a new goal gets a different id and un-suppresses automatically. Active states (intake/running/paused) are not dismissible while the lock they explain is still in force.
|
|
42
|
+
|
|
34
43
|
### Graceful interrupt
|
|
35
44
|
Sending a new message while the agent is generating **does not** abort — the message goes to the server queue, the agent finishes the current turn at the next safe checkpoint (after a tool call), and then runs the queued message. Queueing multiple messages is supported; they run in order.
|
|
36
45
|
|
|
@@ -6,7 +6,7 @@ Unified command processing — all slash commands (built-in + skill) are dispatc
|
|
|
6
6
|
|
|
7
7
|
Source: [commands/index.ts](../../../packages/server/src/commands/index.ts) (descriptors) + [channels/shared/commands.ts](../../../packages/server/src/channels/shared/commands.ts) (execution)
|
|
8
8
|
|
|
9
|
-
Top-level built-in slash commands: `/help` `/evo` `/session` `/agent` `/skill` `/workspace`. (`/cron` and `/acp` look like top-level commands too but are provided by same-name skills — see the verb table below; they aren't registered in `commands/index.ts` or `DISPATCH_COMMANDS`.)
|
|
9
|
+
Top-level built-in slash commands: `/help` `/evo` `/session` `/agent` `/skill` `/workspace` `/goal`. (`/cron` and `/acp` look like top-level commands too but are provided by same-name skills — see the verb table below; they aren't registered in `commands/index.ts` or `DISPATCH_COMMANDS`.)
|
|
10
10
|
|
|
11
11
|
| Name | Slash | Type | Purpose |
|
|
12
12
|
|---|---|---|---|
|
|
@@ -23,9 +23,20 @@ The rest are noun-verb **object commands**: `/<obj> <verb> [args]`. Some verbs a
|
|
|
23
23
|
| `/workspace` | info (built-in, no gate) · switch (built-in, full) · setup / tidy (skill verb, workspace) · share (skill verb, full) — `/w` is a built-in alias (see [Command aliases](#command-aliases)) |
|
|
24
24
|
| `/cron` | create / list / update / enable / disable / delete — all skill verbs, full |
|
|
25
25
|
| `/acp` | kiro / claude (ask a local agent directly; question = rest of line) · add / list / remove (manage generated ask-* bindings) — all full |
|
|
26
|
+
| `/goal` | create \[description\] / status / pause / resume / clear — all built-in, **all full** (no skill fall-through; the `goal` agent is internal, not a skill). See [design/goal-mode.md](../design/goal-mode.md) |
|
|
26
27
|
|
|
27
28
|
Session lifecycle actions (`session:clear`, `session:delete`) are handled inline by the WS handler, not as slash commands.
|
|
28
29
|
|
|
30
|
+
### `/goal` verbs
|
|
31
|
+
|
|
32
|
+
All five verbs are gated `requiresAccess: full` — **including `status`** (user ruling): goal mode drives an autonomous multi-round loop (the goal session dispatches work orders that write files, run shell checks, and burn rounds of model budget), so no verb is exposed to workspace-level callers.
|
|
33
|
+
|
|
34
|
+
- `create [description]` — start goal intake on the current session (which becomes the worker; must be a root session and not itself a goal session). Refuses while a goal is active — goals are serialized per workspace — printing the active goal's status instead. Returns `switchTo` to the new goal session so the surface lands in the intake conversation.
|
|
35
|
+
- `status` — print the latest goal (any state): status, round/cap, elapsed, no-progress counter, delegated decisions, both session ids, halt reason if any.
|
|
36
|
+
- `pause` — running → paused, then stop the worker (cascading to its subtree) **and** the goal session. Paused lifts the routing overlay: the user talks to the worker directly (manual takeover).
|
|
37
|
+
- `resume` — paused → running, nudges the goal session to re-read spec + transcript and re-dispatch; returns `switchTo` to the goal session.
|
|
38
|
+
- `clear` — tear down the binding from any active state; worker + goal session stopped, surface returns to the worker. The goal record stays on the goal session's row as history.
|
|
39
|
+
|
|
29
40
|
### `/session info`
|
|
30
41
|
|
|
31
42
|
Built-in verb (no access gate beyond ownership). Prints the **session tree** for the caller's current root session — the root plus every descendant sub-agent, so the user can see the whole delegation fan-out at a glance:
|