@turmind/halo 0.2.4 → 0.2.6

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 (29) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/212-e217e00ce6cc2bdc.js +1 -0
  3. package/admin-out/_next/static/chunks/397-fc5a406d80d46ca7.js +1 -0
  4. package/admin-out/_next/static/chunks/app/page-635d9668d47a2b9e.js +1 -0
  5. package/admin-out/_next/static/css/57622245d7724ee0.css +3 -0
  6. package/admin-out/index.html +1 -1
  7. package/admin-out/index.txt +5 -5
  8. package/bundled-docs/design/cron.md +16 -3
  9. package/bundled-docs/design/goal-mode.md +141 -0
  10. package/bundled-docs/design/session.md +16 -0
  11. package/bundled-docs/design/ws.md +30 -3
  12. package/bundled-docs/dev/api.md +24 -2
  13. package/bundled-docs/dev/desktop-packaging.md +20 -0
  14. package/bundled-docs/dev/env.md +11 -1
  15. package/bundled-docs/dev/tools.md +56 -1
  16. package/bundled-docs/guide/cli.md +2 -2
  17. package/bundled-docs/requirements/chat.md +9 -0
  18. package/bundled-docs/requirements/command.md +12 -1
  19. package/dist/index.js +2318 -1176
  20. package/package.json +1 -1
  21. package/templates/agents/goal/AGENT.md +165 -0
  22. package/templates/agents/goal/agent.yaml +34 -0
  23. package/templates/schema.sql +3 -1
  24. package/admin-out/_next/static/chunks/212-170855bed19f67f3.js +0 -1
  25. package/admin-out/_next/static/chunks/307-d7a99f9a4a436ec3.js +0 -1
  26. package/admin-out/_next/static/chunks/app/page-c432ffb80510ae48.js +0 -1
  27. package/admin-out/_next/static/css/66df341f99220f5c.css +0 -3
  28. /package/admin-out/_next/static/{JcY4qrO0pTA-AmuD8SM1y → TmICbj6oGkbeEcs8tFjd3}/_buildManifest.js +0 -0
  29. /package/admin-out/_next/static/{JcY4qrO0pTA-AmuD8SM1y → TmICbj6oGkbeEcs8tFjd3}/_ssgManifest.js +0 -0
@@ -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,10 +89,14 @@ 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 |
80
101
  | `session:compacted` | compact handler | Compaction complete |
81
102
  | `compact:started` / `compact:summarizing` / `compact:done` | compact handler | Compaction progress |
@@ -109,6 +130,12 @@ UI state (messageLog / streamBuffer / turnToolCalls / tokens) belongs to Session
109
130
  - `command:session` with `compact` verb — calls `sm.compactSession(sid, { onProgress })` directly for real-time progress feedback
110
131
  - All other `command:*` — builds a shared `CommandContext` and routes through `dispatchCommand()` (see [command.md](command.md))
111
132
 
133
+ ### switchTo rebind & `session:switched`
134
+
135
+ 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.
136
+
137
+ 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`).
138
+
112
139
  ### Message flow
113
140
 
114
141
  1. `chat` → check if the session exists → `SessionManager.createSession()` or `sendUserMessage()`
@@ -8,7 +8,7 @@ Auth: most `/api/*` routes require a valid JWT cookie (`halo_token`). Exceptions
8
8
 
9
9
  | Method | Path | Purpose |
10
10
  |---|---|---|
11
- | GET | `/api/health` | Health check — returns `{status, timestamp, uptime, engine}` |
11
+ | GET | `/api/health` | Health check — returns `{status, timestamp, uptime, engine, version, gitSha}` (`gitSha`: short sha on source builds, `-dirty` suffix if tree modified, `null` on published bundles) |
12
12
 
13
13
  ## Authentication
14
14
 
@@ -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
 
@@ -527,6 +528,25 @@ Returns the full session file. If only `rawMessages` is present (sub-agent sessi
527
528
  }
528
529
  ```
529
530
 
531
+ ### GET `/api/sessions/goal?projectId=<abs>`
532
+
533
+ Source: `packages/server/src/routes/sessions.ts` (`findLatestGoal` in `agents/goal-mode.ts`)
534
+
535
+ 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).
536
+
537
+ ```json
538
+ // 200
539
+ {
540
+ "goal": {
541
+ "goalSessionId": "goal_mabc123",
542
+ "workerSessionId": "sid_abc",
543
+ "status": "running", // intake | running | paused | halted | done
544
+ "round": 3,
545
+ "maxRounds": 50
546
+ }
547
+ }
548
+ ```
549
+
530
550
  ### GET `/api/settings/schema?projectId=<abs>`
531
551
 
532
552
  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 +654,7 @@ Full WS protocol in [design/ws.md](../design/ws.md). The four high-traffic clien
634
654
  "projectId": "/abs/ws",
635
655
  "message": "hello",
636
656
  "agentId": "default", // optional override
657
+ "clientMsgId": "abc123", // optional — ack/resend dedup id
637
658
  "images": [ { "data": "<base64>", "mimeType": "image/png" } ] // optional
638
659
  }
639
660
  ```
@@ -643,6 +664,7 @@ Server behaviour ([handler.ts `handleChat`](../../../packages/server/src/ws/hand
643
664
  - Persists pasted images to `<ws>/.halo/web/inbound/<date>/`
644
665
  - 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
666
  - Queues if busy/compacting; otherwise runs the agent turn
667
+ - 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
668
  - Streams events back via `message`, `tool_call`, `usage`, etc.
647
669
 
648
670
  ### `subscribe` (C→S)
@@ -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
@@ -11,6 +11,16 @@
11
11
  - Password: set via `halo setup` (stored as scrypt hash in `~/.halo/secrets/config.yaml server.password`) **or** `HALO_PASSWORD` env (plaintext — preferred for Docker / CI). Env wins over hash if both are set.
12
12
  - Workspace path: any absolute directory you want to bind
13
13
 
14
+ ## Dev mode (hot reload)
15
+
16
+ ```bash
17
+ pnpm run dev # root script: runs all packages' dev in parallel
18
+ ```
19
+
20
+ - **admin**: `next dev` on :3000 (`next start` is not an option — it rejects `output: 'export'`); API/WS requests are proxied to :9527 via `next.config.ts` rewrites, so the server must be running too
21
+ - **Monaco in dev**: `copy-monaco.mjs --dev` stages `min/vs` into `packages/admin/public/monaco/` (gitignored) so `/monaco/vs` resolves on :3000; the production build path (`out/monaco/vs`) is unchanged
22
+ - **server**: `tsx watch src/index.ts` on :9527
23
+
14
24
  ## Deploy commands
15
25
 
16
26
  ### Frontend build (web only)
@@ -91,7 +101,7 @@ Source: `packages/server/src/config.ts`
91
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. |
92
102
  | `HALO_FRONTEND_DIR` | `packages/admin/out` | — | Static frontend dir (resolved as absolute path from project root) |
93
103
  | `HALO_MAX_CONTEXT_TOKENS` | `200000` | — | Model max context |
94
- | `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) |
95
105
  | `HALO_WEB_FETCH_TIMEOUT` | `10000` | `config.yaml timeout.web_fetch` | web_fetch timeout |
96
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. |
97
107
  | `HALO_SESSION_GRACE` | `300000` | `config.yaml timeout.session_grace` | WS-disconnect session grace |
@@ -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
 
@@ -286,6 +286,61 @@ Get an agent's full details: AGENT.md, model config, tools, skills. Use it befor
286
286
 
287
287
  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
288
 
289
+ ## Goal tools
290
+
291
+ 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).
292
+
293
+ ### goal_context
294
+
295
+ 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.
296
+
297
+ 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).
298
+
299
+ ### goal_attach
300
+
301
+ 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.
302
+
303
+ | Arg | Type | Required | Description |
304
+ |---|---|---|---|
305
+ | `kickoff` | string | yes | Round-1 work order, sent verbatim (header prepended by the platform) |
306
+ | `caps` | object | no | Overrides pinned during intake: `max_rounds` / `max_hours` / `max_tokens`; omitted fields keep defaults (10 rounds / 4h / no token budget) |
307
+ | `decision_policy` | string | no | One-line record of what kinds of forks the user delegated |
308
+
309
+ ### goal_decide
310
+
311
+ 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`.
312
+
313
+ | Arg | Type | Required | Description |
314
+ |---|---|---|---|
315
+ | `question` | string | yes | The fork the worker raised |
316
+ | `decision` | string | yes | What G decided |
317
+ | `rationale` | string | no | Why the contract/scene supports it |
318
+
319
+ ### goal_finish
320
+
321
+ 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.
322
+
323
+ | Arg | Type | Required | Description |
324
+ |---|---|---|---|
325
+ | `summary` | string | yes | One-line result recorded in the goal state |
326
+
327
+ ### query_session (goal-scoped)
328
+
329
+ 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.
330
+
331
+ | Arg | Type | Required | Description |
332
+ |---|---|---|---|
333
+ | `session_id` | string | yes | Must be the bound worker session id |
334
+ | `message` | string | yes | The work order / relayed answer / steering update |
335
+
336
+ ### get_session_output (goal-scoped)
337
+
338
+ 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.
339
+
340
+ | Arg | Type | Required | Description |
341
+ |---|---|---|---|
342
+ | `session_id` | string | yes | Worker session id or a descendant (`worker>child`) id |
343
+
289
344
  ## Self-review tool
290
345
 
291
346
  ### draft
@@ -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
 
@@ -192,7 +192,7 @@ Attach files or images to your message with `@file` and `@image`, or pull a dire
192
192
  ```
193
193
  packages/cli/
194
194
  src/
195
- index.ts — Entry: subcommand dispatch (tui/cli/agents/sessions/server/setup/acp), SIGINT handling
195
+ index.ts — Entry: subcommand dispatch (tui/cli/agents/sessions/server/setup/acp), SIGINT/SIGTERM handling
196
196
  harness.ts — Shared agent harness wrapping SessionManager
197
197
  cli.ts — Non-interactive: stdin/args → agent → stdout → exit
198
198
  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: