@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.
Files changed (44) hide show
  1. package/README.md +5 -5
  2. package/admin-out/404.html +1 -1
  3. package/admin-out/_next/static/chunks/212-e217e00ce6cc2bdc.js +1 -0
  4. package/admin-out/_next/static/chunks/397-fc5a406d80d46ca7.js +1 -0
  5. package/admin-out/_next/static/chunks/app/page-635d9668d47a2b9e.js +1 -0
  6. package/admin-out/_next/static/css/57622245d7724ee0.css +3 -0
  7. package/admin-out/index.html +1 -1
  8. package/admin-out/index.txt +5 -5
  9. package/bundled-docs/design/agent.md +12 -2
  10. package/bundled-docs/design/cron.md +16 -3
  11. package/bundled-docs/design/goal-mode.md +141 -0
  12. package/bundled-docs/design/session.md +25 -0
  13. package/bundled-docs/design/ws.md +34 -3
  14. package/bundled-docs/dev/add-model-provider.md +17 -9
  15. package/bundled-docs/dev/api.md +27 -1
  16. package/bundled-docs/dev/deploy.md +4 -8
  17. package/bundled-docs/dev/desktop-packaging.md +20 -0
  18. package/bundled-docs/dev/env.md +10 -5
  19. package/bundled-docs/dev/tools.md +60 -4
  20. package/bundled-docs/guide/cli.md +3 -2
  21. package/bundled-docs/requirements/chat.md +9 -0
  22. package/bundled-docs/requirements/command.md +12 -1
  23. package/dist/index.js +2371 -1254
  24. package/package.json +1 -1
  25. package/templates/agents/goal/AGENT.md +165 -0
  26. package/templates/agents/goal/agent.yaml +34 -0
  27. package/templates/models/anthropic.yaml +1 -0
  28. package/templates/models/aws-bedrock-claude-invoke.yaml +7 -0
  29. package/templates/models/aws-bedrock-mantle.yaml +51 -11
  30. package/templates/models/deepseek.yaml +2 -0
  31. package/templates/models/doubao.yaml +4 -0
  32. package/templates/models/hunyuan.yaml +1 -0
  33. package/templates/models/kimi.yaml +49 -13
  34. package/templates/models/mimo-token-plan-china.yaml +2 -0
  35. package/templates/models/minimax.yaml +37 -5
  36. package/templates/models/openai.yaml +1 -0
  37. package/templates/models/qwen.yaml +2 -0
  38. package/templates/schema.sql +3 -1
  39. package/admin-out/_next/static/chunks/212-170855bed19f67f3.js +0 -1
  40. package/admin-out/_next/static/chunks/307-d7a99f9a4a436ec3.js +0 -1
  41. package/admin-out/_next/static/chunks/app/page-96fce52cfe97cc9b.js +0 -1
  42. package/admin-out/_next/static/css/6f53bae6f1952300.css +0 -3
  43. /package/admin-out/_next/static/{P60h_WmaPcXYhIabUg6I2 → gzvax-Lg7K157j0rQJHje}/_buildManifest.js +0 -0
  44. /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 an `OPEN` socket whose send buffer stops draining (probed with an app-level `__ping__` the server ignores). Any of these force-closes the socket so the exponential-backoff reconnect (1 s → 30 s cap) runs. The OS `online` event additionally triggers an immediate `reconnectIfStale(0)`. (Earlier `visibilitychange`/`focus` probes were removed — inside iframes they fire constantly and tore down healthy connections.)
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 (reasoning_content) |
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), no vision |
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
- | `aws-bedrock-mantle` | `MantleAgent` | `mantle-agent.ts` | OpenAI GPT-5.x (5.5 / 5.4) 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`. With reasoning on, returns streaming snapshots as multiple completed items — see the "Bedrock-Mantle returns streaming snapshots as separate output items" gotcha below. |
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 returns streaming snapshots as separate output items
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
- `aws-bedrock-mantle` (GPT-5.x via Bedrock's OpenAI Responses surface) has a server-side quirk: with **reasoning enabled**, it returns 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:
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
- - **`message`**: emitted as `reasoning → message → reasoning → message …`, each later `message` repeating the prior as a growing superset → `MantleAgent` keeps only the **last** message item (see `mantle-agent.ts`, the `message` branch).
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
- 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 is in Bedrock-Mantle, not halo — first-party OpenAI returns a single final item. If Bedrock ever fixes this, the "last only" / completeness filters become no-ops, not regressions.
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
- Diagnosis note: the session file's `rawMessages` stores arguments **after** `strictParseOrNull`, so truncated snapshots already show as `{}` — they look like "empty tool calls", not snapshots. 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).
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
 
@@ -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. Create the runtime directory
35
+ ## 2. Runtime data locations
36
36
 
37
- ```bash
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 root `package.json` (and any workspace `package.json` that mirrors it).
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
@@ -67,7 +67,7 @@ Your deployment scripts should also prepend this line.
67
67
 
68
68
  ## Runtime directories
69
69
 
70
- - SQLite: `data/halo.db` (auto-created on first start)
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` | `120000` | `config.yaml timeout.shell_exec` | Shell command timeout (ms) |
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.hidden_files` (default `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc`) — bwrap /dev/null binds, scope: global
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 120 s (`HALO_SHELL_TIMEOUT`). Max output 5 MB.
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` | 120,000 ms | Shell command timeout |
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: