@turmind/halo 2.0.8 → 2.1.1

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 (41) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/{241-75bac3bb8e7aca1b.js → 398-9a8be186767b4504.js} +1 -1
  3. package/admin-out/_next/static/chunks/989-71c38d29c6672b27.js +1 -0
  4. package/admin-out/_next/static/chunks/app/page-67334fcd2175c7ad.js +1 -0
  5. package/admin-out/_next/static/css/3467b854242306b8.css +1 -0
  6. package/admin-out/index.html +1 -1
  7. package/admin-out/index.txt +5 -5
  8. package/bundled-docs/design/agentcore.md +1 -1
  9. package/bundled-docs/design/architecture.md +5 -3
  10. package/bundled-docs/design/canvas-extensions.md +1 -1
  11. package/bundled-docs/design/node.md +98 -0
  12. package/bundled-docs/design/prompt-system.md +2 -0
  13. package/bundled-docs/design/storage.md +4 -4
  14. package/bundled-docs/design/ws.md +8 -7
  15. package/bundled-docs/dev/api.md +21 -0
  16. package/bundled-docs/dev/env.md +2 -2
  17. package/bundled-docs/dev/tools.md +58 -1
  18. package/bundled-docs/guide/README.md +4 -0
  19. package/bundled-docs/guide/agents.md +1 -1
  20. package/bundled-docs/guide/cli.md +23 -0
  21. package/bundled-docs/guide/delegation-and-access.md +2 -2
  22. package/bundled-docs/guide/node.md +86 -0
  23. package/bundled-docs/guide/workspace.md +6 -3
  24. package/bundled-docs/requirements/agent.md +1 -1
  25. package/bundled-docs/requirements/terminal.md +76 -44
  26. package/bundled-docs/requirements/workspace.md +12 -1
  27. package/dist/evo-wrapper.js +7 -5
  28. package/dist/index.js +1997 -844
  29. package/package.json +3 -1
  30. package/templates/agents/default/agent.yaml +2 -0
  31. package/templates/config.yaml +0 -6
  32. package/templates/models/aws-bedrock-mantle.yaml +44 -4
  33. package/templates/models/aws-bedrock-openai.yaml +44 -4
  34. package/templates/models/doubao.yaml +97 -7
  35. package/templates/models/kimi.yaml +41 -3
  36. package/templates/models/minimax.yaml +35 -4
  37. package/admin-out/_next/static/chunks/989-af7e27a2004069ac.js +0 -1
  38. package/admin-out/_next/static/chunks/app/page-2af2482b39d6ec64.js +0 -1
  39. package/admin-out/_next/static/css/2e6b00182ac44ae1.css +0 -1
  40. /package/admin-out/_next/static/{pBIsb2BS3McG20ieKaTDx → 0cRHmPZfENhrU7Hb0w3n_}/_buildManifest.js +0 -0
  41. /package/admin-out/_next/static/{pBIsb2BS3McG20ieKaTDx → 0cRHmPZfENhrU7Hb0w3n_}/_ssgManifest.js +0 -0
@@ -25,7 +25,7 @@ Two OS/browser signals supplement the timer:
25
25
 
26
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.
27
27
 
28
- After a successful `_connected` event, session-resume messages are re-issued: `subscribe` (chat / agent state — sent by **use-websocket.ts only**, and only for the **chat tab on screen**: `releaseBackgroundTabs()` in `chat-tabs.ts` drops every background tab's store, keeping only its header, and returns the active tab's session, `''` for a draft; a released tab subscribes again when it is next shown. The `_connected` subscribe is skipped while `useChatTabs.projectId` is not yet the active project — `restoreTabs` subscribes once it runs, and it is also the only subscriber on a workspace switch. A second subscribe on reconnect would consume the detached-session reattach and then re-run the normal path over it) and `terminal:reattach` (PTY pool, terminal-panel.tsx).
28
+ After a successful `_connected` event, session-resume messages are re-issued: `subscribe` (chat / agent state — sent by **use-websocket.ts only**, and only for the **chat tab on screen**: `releaseBackgroundTabs()` in `chat-tabs.ts` drops every background tab's store, keeping only its header, and returns the active tab's session, `''` for a draft; a released tab subscribes again when it is next shown. The `_connected` subscribe is skipped while `useChatTabs.projectId` is not yet the active project — `restoreTabs` subscribes once it runs, and it is also the only subscriber on a workspace switch. A second subscribe on reconnect would consume the detached-session reattach and then re-run the normal path over it) and `terminal:reattach` (terminal-panel.tsx: re-attaches every PTY of the workspace — a snapshot each, then live output — and re-subscribes to PTYs other pages start there).
29
29
 
30
30
  ### Abandoned-listener reclaim and the `__ping__` contract
31
31
 
@@ -68,11 +68,11 @@ Both directions are typed in `packages/core/src/protocol/ws-frames.ts` (`WsClien
68
68
  | `session:delete` | Delete session files + cascade-delete descendants in SQLite (handled inline), then release this connection's listener for it (no save — the session is gone) |
69
69
  | `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 turn's copy from `rawMessages` (LLM context; located by the row's `stamp` — a row from before stamps by its text, among old rows and turns only) → `deleteExchange`. Fields: `userOrdinal` (0-based index among *main-conversation* user turns, i.e. excluding `taskId` sub-agent messages — matches the admin's `isMainConversationMessage` count), `archiveCount` (the archived-segment count the client's view was opened against — its archive anchor, from the `state:snapshot` it got on subscribe), optional `sessionId` / `projectId`. Rejected (→ `error`) while the session is running or compacting. The server also compares `archiveCount` against the archive segments on disk (`countArchiveSegments`); only a **mismatch** is refused, with `code: 'archived'` ("… archived history since it was opened — reopen it to delete individual turns") — a session with archives can still be deleted from as long as the anchor matches. A turn after the last compaction whose raw copy can't be located (a stamped row whose block raw doesn't hold — never a text fallback; an unstamped row merged with others, or its text not found), a stamp shared by two rows or two raw blocks, or a summary row is refused with `code: 'raw_unmatched'`, nothing changed. See [session.md](session.md#exchange-deletion-soft-ui--hard-raw). |
70
70
  | `command:<name>` | Route through shared `dispatchCommand` (see [command.md](command.md)); `/session compact` handled inline for UI callbacks |
71
- | `terminal:start` | Spawn a new PTY |
72
- | `terminal:input` | Send keystrokes. `terminalId` is **required** — an id-less frame is logged and dropped (it used to fall back to the first entry of the process-global terminal map, which with several admin connections open could write into another browser's PTY) |
73
- | `terminal:resize` | Resize terminal. `terminalId` required, same rejection as `terminal:input` |
74
- | `terminal:close` | Kill the PTY |
75
- | `terminal:reattach` | Re-attach every detached terminal after reconnect |
71
+ | `terminal:start` | Spawn a new PTY in `workspacePath`, named `name` (the tab name every page shows); pushed to the workspace's other subscribers as `terminal:opened` |
72
+ | `terminal:input` | Send keystrokes; the sender becomes the PTY's size driver (PTY switches to the size it last reported). `terminalId` is **required** — an id-less frame is logged and dropped (it used to fall back to the first entry of the process-global terminal map, which could write into a PTY of another workspace) |
73
+ | `terminal:resize` | Report this page's size; applied when the sender is the driver or no driver is attached, otherwise only recorded. `terminalId` required, same rejection as `terminal:input` |
74
+ | `terminal:close` | Kill the PTY — every attached page gets `terminal:exit` |
75
+ | `terminal:reattach` | `{workspacePath}` — attach to every PTY of that workspace (any browser / device: no per-browser owner) and subscribe to the workspace |
76
76
 
77
77
  `session:clear` (and its `session:cleared` reply) was removed in 1.5.3-alpha. "Start fresh" (`/session new`, `/clear`, "+ New Session", the Agents tab's Test button) now opens a **draft tab** on the client (`newTab` in `chat-tabs.ts`) and sends nothing. The previous session stays subscribed in its own tab. A draft gets its session id on its first `chat` or `command:*`, which the server's `bindOrCreateSession` creates and subscribes.
78
78
 
@@ -133,7 +133,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
133
133
  | `listener:released` | `reclaimIfAbandoned` (this client only) | One of this connection's event listeners was reclaimed (silent >3 min / CLOSED) — `{sessionId}` (never null), one frame per released session. Client must re-`subscribe` to reattach; the admin does so only for the tab on screen. See [Abandoned-listener reclaim](#abandoned-listener-reclaim-and-the-__ping__-contract). |
134
134
  | `chat:queued` | `handleChat` while the session is compacting | User-message-queued notification — `{reason, message, sessionId}` |
135
135
  | `file:changed` | WorkspaceWatcher · GitDirWatcher · `routes/git.ts` | File change notification (path + action). Three sources: (1) **WorkspaceWatcher** — recursive workspace watch, deliberately excludes `.git`, one per workspace root shared across connections (`ws/watcher-pool.ts`); (2) **`routes/git.ts`** — every git mutation route re-broadcasts `path:'.git'` itself (the recursive watcher ignores `.git`), via `broadcastToWorkspace` so only clients bound to that workspace are woken (a git write in A used to make every tab showing B refetch status + ignored + log); (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). |
136
- | `terminal:ready` / `terminal:output` / `terminal:exit` / `terminal:reattached` | TerminalManager | PTY output |
136
+ | `terminal:ready` / `terminal:output` / `terminal:exit` / `terminal:reattached` / `terminal:opened` / `terminal:snapshot` | TerminalManager | PTY output / exit to every attached page; `reattached {terminals:[{id,name}]}` answers `terminal:reattach`; `opened {terminalId,name}` pushes a PTY another page started in a subscribed workspace; `snapshot {terminalId,data,cols,rows}` is the serialized headless mirror sent on each attach, joined to the live stream with nothing lost or repeated. See [requirements/terminal.md](../requirements/terminal.md#shared-persistent-terminals) |
137
137
  | `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). |
138
138
  | `session:switched` | handler.ts (this client only) | The server added a session to this connection's subscription set after a command `switchTo` or a goal-mode divert — `{sessionId, fromSessionId?, clientMsgId?}`. See [switchTo & `session:switched`](#switchto--sessionswitched) |
139
139
  | `goal:changed` | `writeGoalState` (`agents/goal-mode.ts`, broadcast to all clients) | Retained legacy event: `{goalSessionId, workerSessionId, status, round, maxRounds}`, no workspace marker. Emitted on goal state writes for existing goal bindings, unchanged. |
@@ -147,6 +147,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
147
147
  | `skills:changed` | `ws/skills-changed.ts` `notifySkillsChanged` (REST `POST` / `DELETE /skills`, `PATCH /skills/:id/toggle`, `/skill disable` · `enable` · `delete`) · `ws/watcher-pool.ts` (the workspace watcher saw `.halo/skills/**` change) | A skill was created / deleted / toggled, or a workspace skill folder changed on disk (an agent's `file_write`, a hand edit). No payload: the admin bumps its skill bus, and the chat slash-command popup, the Skills page and the agent form's skill picker re-fetch. Scoped on the server: a workspace skill or a toggle (the disable flag lives in that workspace's db) goes via `broadcastToWorkspace`, a global skill via `broadcast`. Only real changes emit: a no-op mutation (409 / 404 / already disabled) sends nothing, and the watcher path compares a per-workspace key (folder ids + each SKILL.md's frontmatter), so body / resource edits and the echo of a mutation that already notified stay silent. The watcher debounce is 300 ms on top of the file watcher's own 300 ms flush. `~/.halo/global/skills/` has no watcher, so a hand edit there shows on the Skills page's window-focus refetch. Also bumped on WS reconnect while the Skills page is open. |
148
148
  | `extension:changed` | `extensions/watcher.ts` `rescanAndBroadcast` (broadcast to all clients) | `~/.halo/global/extensions/` changed (install / upgrade / uninstall / on-off switch, from the admin upload, the one-click update, the `extension` skill, or a manual `mv`/`rm`; a switch rewrites `.state.json`, which trips the same watcher) — carries the **full** `ExtensionsSnapshot` (`{extensions, disabled, errors}`: switched on, switched off, refused), not a diff, and only fires when the snapshot key differs from the last one. The admin replaces its extension list wholesale (a missing `disabled`, from an older server, reads as `[]`): the preview registry re-resolves open tabs from `extensions` only (an upgraded extension remounts, or shows a banner if the tab is dirty; an uninstalled or switched-off one falls back to the "no built-in preview" page, or keeps a dirty tab alive with a banner) and the Settings → Extensions panel re-renders. Global, no workspace marker — extensions are server-wide. See [canvas-extensions.md](canvas-extensions.md). |
149
149
  | `updates:changed` | `updates/core-check.ts` · `updates/extension-check.ts` (broadcast to all clients) | An update check finished, or (extensions scope) a watcher rescan changed a cached verdict — `{scope: 'core' \| 'extensions', status}`, `status` = the same `CoreUpdateStatus` / `ExtensionsUpdateStatus` that `GET /api/updates?scope=…` returns. Checks start only from that GET (cache ≥ 24 h old) or `POST /api/updates/check`, never on a timer. The Settings sidebar foot (`core`) and Settings → Extensions (`extensions`) replace their status with it; nothing else listens. Global, no workspace marker. See [canvas-extensions.md → Updates](canvas-extensions.md#updates-updatejson). |
150
+ | `nodes:changed` | `nodes/hub.ts` `announce` (broadcast to all clients) | A `halo node` connected or disconnected — carries the **full** list of online machines, `{online: [{accountId, root, shell, os, since}]}` (`root: null` = whole-machine mode), not a diff. The admin's Channels → Linked machines page replaces its online map wholesale and re-fetches the account list on WS reconnect. Global, no workspace marker (the channels page is a global view). See [node.md](node.md). |
150
151
 
151
152
  ### `error` frames: optional `code`
152
153
 
@@ -338,6 +338,26 @@ SSE responses (`/api/web/chat`, `/api/web/subscribe`) carry an SSE comment line
338
338
 
339
339
  See [design/web.md](../design/web.md).
340
340
 
341
+ ## Linked machines (`halo node`)
342
+
343
+ File: `packages/server/src/nodes/routes.ts` (+ `hub.ts` for the WebSocket). Not mounted in AgentCore mode. Design: [design/node.md](../design/node.md).
344
+
345
+ ### Admin endpoints (cookie auth)
346
+
347
+ | Method | Path | Purpose |
348
+ |---|---|---|
349
+ | GET | `/api/nodes/accounts` | All machine tokens → `{accounts: [{accountId, name, workspacePath, workspaceMissing, token, createdAt, online: {root \| null, shell, os, since} \| null}]}` (`root: null` = whole-machine mode) |
350
+ | POST | `/api/nodes/accounts` | Create. Body `{workspacePath, name}` (`name` = `^[a-z0-9][a-z0-9-]{0,31}$`) → `{accountId, token, name, workspacePath}`. 400 bad name / workspace (must be an absolute path that exists; `.halo/` is created if missing), 409 name already used in that workspace |
351
+ | DELETE | `/api/nodes/accounts/:id` | Delete; a connected machine is closed with `4001` and exits → `{ok: true}` |
352
+
353
+ ### Machine connection
354
+
355
+ `GET /api/nodes/connect` + WebSocket upgrade, headers `authorization: Bearer <token>`, `x-halo-node-protocol: 1`, `x-halo-node-instance: <uuid>`, `x-halo-node-info: <base64url JSON>`. Refusals before the upgrade: 429 locked out (5 bad tokens / 15 min, `node-token` bucket) · 426 protocol · 401 token · 404 workspace gone · 400 info / instance missing · 409 connected from another instance. Frames and close codes: [design/node.md](../design/node.md#protocol-v1).
356
+
357
+ ### Internal relay
358
+
359
+ `POST /api/nodes/call` — loopback only and header `x-halo-node-relay: <per-boot secret>` (else 403); in `PUBLIC_PATHS`, used by cron's `halo cli` child. Body `{workspace, op, node?, args?}` (`op: 'nodes'` = node_list) → `{ok: true, text}` or `{ok: false, error}`. Not a public API.
360
+
341
361
  ## A2A (Agent-to-Agent)
342
362
 
343
363
  Files: `packages/server/src/a2a/routes.ts` (JSON-RPC handler), `exposure.ts` (path resolution, auth, card), `outbound.ts` (push receiver). A2A v1.0, JSON-RPC binding only. Mounted in normal server mode outside `/api/*` (no admin cookie) and before the static frontend; not in the CLI / TUI. AgentCore mode serves the same handler at `/` instead — see [AgentCore runtime container](#agentcore-runtime-container-halo-agentcore-only). Design: [design/a2a.md](../design/a2a.md).
@@ -1082,5 +1102,6 @@ It does not remove session files. The full delete (rows + JSON + archive segment
1082
1102
  | `chat:ack` | `clientMsgId` | Chat appended to the session log (or re-acked on resend) |
1083
1103
  | `listener:released` | `sessionId` | Event listener reclaimed — re-send `subscribe` (see above) |
1084
1104
  | `skills:changed` | — | A skill was created / deleted / toggled, or a workspace skill folder changed on disk — re-fetch skill lists and `/api/commands`. Workspace-scoped changes reach only that workspace's sockets (see [Skills](#skills)) |
1105
+ | `nodes:changed` | `online: [{accountId, root \| null, shell, os, since}]` | A `halo node` connected / disconnected — full snapshot of online machines (global broadcast; `root: null` = whole machine). See [Linked machines](#linked-machines-halo-node) |
1085
1106
 
1086
1107
  Events with `taskId` set belong to sub-agent turns (nested sessions); events without are the root agent's. Session-scoped frames carry `sessionId` (the root session they belong to); the admin routes each one to the tab holding that session, and a frame without one goes to the tab on screen. Full list in [design/ws.md](../design/ws.md).
@@ -106,7 +106,6 @@ Source: `packages/server/src/config.ts`
106
106
  | `HALO_WEB_FETCH_TIMEOUT` | `10000` | `config.yaml timeout.web_fetch` | web_fetch timeout |
107
107
  | `HALO_MODEL_TIMEOUT` | `600000` | `config.yaml timeout.model_request` | Per model-call idle timeout (ms, 10 min): every provider streams (Bedrock, the Anthropic-Messages providers anthropic / mimo / minimax / qwen, the OpenAI-family openai / deepseek / kimi / zhipu / doubao / hunyuan, and Mantle aws-bedrock-mantle / aws-bedrock-openai) and re-arms it on any data the stream delivers — text / thinking deltas, pings, tool-argument fragments, empty reasoning chunks, SSE comment frames — so it fires only when the model goes silent for this long, not on a long but live answer. It is also the only bound on a compact's LLM summary (no separate compact timeout). 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. |
108
108
  | `HALO_SESSION_GRACE` | `300000` | `config.yaml timeout.session_grace` | WS-disconnect session grace |
109
- | `HALO_TERMINAL_GRACE` | `300000` | `config.yaml timeout.terminal_grace` | Terminal disconnect grace |
110
109
  | `HALO_MAX_CACHED_SESSIONS` | `50` | — | In-memory session cache |
111
110
  | `HALO_LOG_MAX_SIZE` | `10485760` | `config.yaml logging.max_file_size` | Log file size cap (10 MB) |
112
111
  | `HALO_LOG_MAX_FILES` | `5` | `config.yaml logging.max_files` | Retained rotated logs |
@@ -123,6 +122,7 @@ Source: `packages/server/src/config.ts`
123
122
  | `HALO_DEFAULT_PROVIDER` | (none) | — | Read by `halo setup --non-interactive`: binds every built-in agent to this provider id (and its default model) and, for each of its env-backed keys whose variable is set, writes the `<<NAME>>` placeholder into `settings.yaml` (Bedrock excepted). An unknown id only prints a warning |
124
123
  | `HALO_CLI` | `halo` (`halo.cmd` on Windows) | — | `halo` cli executable that cron runs and the evolution wrapper spawn — a dev override |
125
124
  | `HALO_VERSION` | `dev` | — | Version string reported by `GET /api/health` and the cli; stamped by the bundle / desktop launcher, so normally not set by hand |
125
+ | `HALO_NODE_TOKEN` | (none) | — | `halo node` only (on the linked machine): pairing token, used when `--token` isn't given — preferred over the flag, which shows up in `ps` / shell history. Stripped from child-process env (see [design/node.md](../design/node.md)) |
126
126
  | `HALO_PROMPT` | (none) | — | `plain` makes `halo setup` use numbered-menu prompts instead of the raw-mode TUI |
127
127
  | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | (none) | `settings.yaml general.observability.capture_content` | `true` / `false` / `1` / `0` (any case) overrides `capture_content` either way; unset or any other value → the setting. Read at process start (see [design/observability.md](../design/observability.md)) |
128
128
 
@@ -169,7 +169,7 @@ settings.yaml-driven `general.limits.*` (no env override):
169
169
  - `general.limits.tool_result_render_chars` (default 8000)
170
170
  - `general.limits.tool_result_ui_chars` (default 64 KiB)
171
171
  - `general.limits.auto_report_chars` (default 8192)
172
- - `general.limits.terminal_scrollback_bytes` (default 50000)
172
+ - `general.limits.terminal_scrollback_lines` (default 5000)
173
173
 
174
174
  Two ways to authenticate:
175
175
 
@@ -535,6 +535,63 @@ The remotes configured on this server (the same list in every workspace) — eac
535
535
 
536
536
  Returns `{ "code": 0, "remotes": [{ name, agent, description, skills }], "pending": [{ remote, context_id, task_id, since }] }`.
537
537
 
538
+ ## Node tools
539
+
540
+ File: `packages/server/src/nodes/tools.ts` (`buildNodeTools`). Design notes in [design/node.md](../design/node.md); user guide [guide/node.md](../guide/node.md).
541
+
542
+ Act on the user's **linked machines** — computers running `halo node`, paired with this workspace in Admin → Channels → Linked machines. Each node tool mirrors one local workspace tool and runs **that same tool** on the machine (same arguments, same output), plus a required `node` argument naming the machine:
543
+
544
+ | Tool | Mirrors | Extra / different args |
545
+ |---|---|---|
546
+ | `node_list` | — | none |
547
+ | `node_read` | `file_read` | `node` |
548
+ | `node_write` | `file_write` | `node` |
549
+ | `node_edit` | `file_edit` | `node` |
550
+ | `node_list_dir` | `file_list` | `node` |
551
+ | `node_grep` | `grep` | `node` |
552
+ | `node_glob` | `glob` | `node` |
553
+ | `node_run` | `shell_exec` | `node`, `command`, `cwd` (default: the machine's folder) |
554
+
555
+ **Enabling**: opt-in by the single name `node_list` in `agent.yaml`'s `tools:` — it brings all eight; the other seven names are not recognised on their own. **Full-access sessions only** (`accessLevel === null`). The bundled `default` agent lists it. The admin tool picker shows one `node_list` chip naming the set. Names, descriptions and schemas are constants — which machines are online never changes the tool list or the system prompt (prompt-cache invariant); online state is only in `node_list`'s result.
556
+
557
+ **Paths**: a machine shares one folder (`--root`; `root` in `node_list`) — relative paths resolve against it, paths that resolve outside it (symlinks included) are refused: `"<p>" is outside this machine's shared folder (<root>) — node tools can only reach paths inside it`. A whole-machine node (`root: null, wholeMachine: true`) has no such check; relative paths resolve against its `base`.
558
+
559
+ **Runtime**: in `halo server` calls go over the machine's WebSocket; cron runs (a `halo cli` child) reach it through the server's loopback relay. In the standalone CLI / TUI, the evo sandbox and AgentCore mode the tools are listed but every one — `node_list` included — returns `linked machines are not available in this runtime — only \`halo server\` accepts \`halo node\` connections. This is permanent for this runtime, not a temporary outage: do not retry. …`.
560
+
561
+ **Errors** (returned as the tool error text):
562
+
563
+ | Case | Message |
564
+ |---|---|
565
+ | Machine linked but not connected | `machine "<n>" is offline (\`halo node\` is not connected). Do not retry in a loop — tell the user to start it.` |
566
+ | Name not linked to this workspace | `unknown machine "<n>" — machines linked to this workspace: <a, b \| (none)> (see node_list)` |
567
+ | Link dropped mid-call | `machine "<n>" went offline during the call (connection lost). The operation may or may not have completed on the machine — check its state before retrying.` |
568
+ | No answer in time (120 s; node_run: shell timeout + 15 s) | `machine "<n>" did not answer within <s>s — the call was cancelled; it may or may not have taken effect.` |
569
+ | node_run without `--allow-shell` | `node_run is disabled on this machine — the user must restart \`halo node\` with --allow-shell to allow commands` |
570
+ | Request over 8 MB | `request too large to send to the machine (<x> MB > 8 MB limit)` |
571
+
572
+ Results over 256K chars are cut on the machine (`[Content truncated on the machine: N chars total]`); the usual tool-result budget then applies.
573
+
574
+ ### node_list
575
+
576
+ The machines linked to this workspace and whether each is online. No arguments. Its description doubles as the usage note for the whole set.
577
+
578
+ Returns `{ "code": 0, "nodes": [...] }`, one entry per linked machine:
579
+
580
+ - offline: `{ "name": "nas", "online": false }`
581
+ - online: `{ "name": "mbp", "online": true, "root": "/Users/a/proj", "shell": true, "shellTimeoutSec": 120, "os": "darwin", "since": "<ISO>" }` — whole-machine: `"root": null, "wholeMachine": true, "base": "<start dir>"`; `shellTimeoutSec` only when `shell` is true.
582
+
583
+ No machines at all → `"nodes": []` plus `"hint": "No machines linked — create a token in Admin → Channels → Linked machines, then run \`halo node\` on the machine."`.
584
+
585
+ ### node_run
586
+
587
+ `shell_exec` on the machine; needs `shell: true` in `node_list`. Runs unsandboxed as the machine's user in `cwd` (default: its folder; in folder mode `cwd` must stay inside it, the command itself is not confined). Killed after `shellTimeoutSec`; output capped like `shell_exec` (~8000 chars to the model) — redirect large output to a file, start long jobs with `nohup … &`. `{{ns.params.x}}` placeholders are not substituted.
588
+
589
+ | Arg | Type | Required | Description |
590
+ |---|---|---|---|
591
+ | `node` | string | yes | Machine name from `node_list` |
592
+ | `command` | string | yes | Shell command |
593
+ | `cwd` | string | no | Directory to run in, like `path` (default: the machine's folder) |
594
+
538
595
  ## Tool assignment
539
596
 
540
597
  Workspace tools are enabled strictly by name in `agent.yaml`'s `tools` list:
@@ -547,7 +604,7 @@ skills:
547
604
  - code-review # auto-injects activate_skill
548
605
  ```
549
606
 
550
- Tools not listed are not injected. Session/delegation tools do **not** go in `tools:` — they ride on a non-empty `team` (see [Session tools](#session-tools) above). `activate_skill` is auto-injected whenever the YAML lists `skills` (no need to put it in `tools`), and `continue_task` is auto-injected for **every** agent unconditionally (see [continue_task](#continue_task)). The relay and A2A sets are the two name-gated bundles: listing `relay_send` alone brings `relay_interrupt` / `relay_stop` / `relay_read` / `relay_list` with it, full-access sessions only (see [Relay tools](#relay-tools)); listing `a2a_send` alone brings `a2a_stop` / `a2a_read` / `a2a_list`, at every access level except the A2A read-only profile (see [A2A tools](#a2a-tools)).
607
+ Tools not listed are not injected. Session/delegation tools do **not** go in `tools:` — they ride on a non-empty `team` (see [Session tools](#session-tools) above). `activate_skill` is auto-injected whenever the YAML lists `skills` (no need to put it in `tools`), and `continue_task` is auto-injected for **every** agent unconditionally (see [continue_task](#continue_task)). The relay, A2A and node sets are the three name-gated bundles: listing `relay_send` alone brings `relay_interrupt` / `relay_stop` / `relay_read` / `relay_list` with it, full-access sessions only (see [Relay tools](#relay-tools)); listing `a2a_send` alone brings `a2a_stop` / `a2a_read` / `a2a_list`, at every access level except the A2A read-only profile (see [A2A tools](#a2a-tools)); listing `node_list` alone brings the other seven `node_*` tools, full-access sessions only (see [Node tools](#node-tools)).
551
608
 
552
609
  There is **no implicit default tool set**: `filterTools()` (in `agent-loader.ts`) returns only the tools whose names appear in `agent.yaml`'s `tools:` list. If the field is absent or empty, the agent has zero workspace tools. The admin UI's "Create agent" form scaffolds a fresh agent with an empty `tools: []` for the same reason — fill it in deliberately. The `default` agent's bundled `agent.yaml` lists the common set (`file_read` / `file_write` / `file_edit` / `view_image` / `file_list` / `shell_exec` / `grep` / `glob` / `web_fetch`) that most agents will want; copy that line if you're starting from scratch.
553
610
 
@@ -24,6 +24,10 @@ Aimed at Halo end-users. Pick the doc that matches your situation.
24
24
  - [WeChat](channels/wechat.md) — QR-scan iLink bot
25
25
  - [ACP](channels/acp.md) — Zed / JetBrains or other ACP clients driving a remote halo
26
26
 
27
+ ## Linked machines
28
+
29
+ [node.md](node.md) — run `halo node` on your own computer so a workspace's agents can work on files there.
30
+
27
31
  ## Related docs
28
32
 
29
33
  - Development: [dev/](../dev/) — environment, API, tool reference
@@ -69,7 +69,7 @@ team: # a non-empty team is what enables delegation
69
69
  - executor # the agent ids this one may spawn
70
70
  ```
71
71
 
72
- **Workspace tools**: `file_read / view_image / file_write / file_edit / file_list / shell_exec / grep / glob / web_fetch` — listed by name under `tools`.
72
+ **Workspace tools**: `file_read / view_image / file_write / file_edit / file_list / shell_exec / grep / glob / web_fetch` — listed by name under `tools`. `node_list` switches on the eight `node_*` tools for [linked machines](node.md) as one set (full-access sessions only).
73
73
 
74
74
  **Session tools**: `start_session / session_list / query_session / interrupt_session / stop_session / archive_session / get_session_output / query_agent` — **not** listed under `tools`. The whole bundle is granted automatically the moment an agent declares a non-empty `team`; an empty/absent `team` means no delegation (no session tools, no roster). To let a sub-agent delegate further, give it its own `team`. Add the agent's own id to its `team` to enable parallel self-spawn.
75
75
 
@@ -308,6 +308,29 @@ the caller-side `auth: sigv4` remote, session dirs and ops notes:
308
308
  [design/agentcore.md](../design/agentcore.md). The hub skill `agentcore-deploy`
309
309
  builds the image and creates the runtime for you.
310
310
 
311
+ ## Linked machine (`halo node`)
312
+
313
+ ```bash
314
+ HALO_NODE_TOKEN=<token> halo node --server https://halo.example.com --root ~/proj [--allow-shell]
315
+ ```
316
+
317
+ Links this machine to a workspace on a halo server, so that workspace's
318
+ full-access agents can work on files here with the `node_*` tools. Runs in the
319
+ foreground, reconnects on its own, Ctrl-C to stop. Needs no `halo setup` and
320
+ no model keys — nothing is written under `~/.halo/`. Create the token in
321
+ Admin → Channels → Linked machines.
322
+
323
+ | Flag / env | Meaning |
324
+ |---|---|
325
+ | `--server <url>` | Server address, `http(s)://host:port` (`ws(s)://` also accepted) — required |
326
+ | `--token <t>` / `HALO_NODE_TOKEN` | Pairing token — required; prefer the env var (flags show up in `ps`) |
327
+ | `--root <dir>` | Folder to share; every path must stay inside it. Omit (or `/`) for whole-machine mode: any path this user can reach, relative paths from the current dir |
328
+ | `--allow-shell` | Let agents run commands (`node_run`) as this user, not sandboxed. Off by default |
329
+ | `--header "Name: value"` | Repeatable. Extra header on the connection, for an auth proxy in front of the server |
330
+
331
+ Exits 1 on a rejected / revoked token, a deleted workspace or a protocol
332
+ mismatch; retries everything else. Full guide: [node.md](node.md).
333
+
311
334
  ## Architecture
312
335
 
313
336
  ```
@@ -94,7 +94,7 @@ Three session levels, stored per session (`agent_sessions.access_level`; `full`
94
94
 
95
95
  | Level | Tools the agent can get (from its `tools:` list) | Sandbox |
96
96
  |---|---|---|
97
- | `full` | all 9 workspace tools | none (the `rm` guard still applies) |
97
+ | `full` | all 9 workspace tools, plus the full-only bundles `relay_send` and `node_list` (linked machines) when listed | none (the `rm` guard still applies) |
98
98
  | `workspace` | all 9 | writes only inside the workspace; credentials and workspace runtime state are hidden |
99
99
  | `readonly` | all 9 with an OS sandbox (writes fail); only `file_read`, `view_image`, `file_list`, `grep`, `glob` without one | nothing writable |
100
100
 
@@ -103,7 +103,7 @@ Extra rules:
103
103
  - A readonly `shell_exec` gets no secret substitution — `{{…params…}}` / `<<ENV>>` placeholders stay literal.
104
104
  - `view_image` also needs a vision-capable model.
105
105
  - A skill with `requiresAccess` above the session level is hidden (no metadata, no `activate_skill`).
106
- - Relay tools are only built for `full` sessions. A2A tools (`a2a_send` in `tools:`) are built at every level except the A2A read-only profile; a non-full session's `files` must sit in the workspace or the temp dir ([dev/tools.md → A2A tools](../dev/tools.md#a2a-tools)).
106
+ - Relay tools and linked-machine tools (`node_list` in `tools:` → the eight `node_*` tools, [node.md](node.md)) are only built for `full` sessions. A2A tools (`a2a_send` in `tools:`) are built at every level except the A2A read-only profile; a non-full session's `files` must sit in the workspace or the temp dir ([dev/tools.md → A2A tools](../dev/tools.md#a2a-tools)).
107
107
  - Hidden from `workspace` / `readonly` sessions only (Full sessions, the admin file explorer and the terminal see everything): a built-in list that is always included (`~/.halo/secrets`, `~/.aws`, `~/.ssh`, `~/.gnupg`, `~/.docker`, `~/.config/gh`, `~/.gitconfig`, `~/.git-credentials`, `~/.npmrc`, `~/.netrc`, the global evo/cron/runs databases, internal-session transcripts, logs, …), any extra paths added in `general.sandbox.hidden_dirs` / `hidden_files` (they add to the built-in list, never replace it), plus the workspace's own `.halo/sessions`, `.halo/logs`, `.halo/evo`, `.halo/halo.db*` (fixed in code). The rest of `.halo/` stays readable.
108
108
 
109
109
  ### Where a session's level comes from
@@ -0,0 +1,86 @@
1
+ # Linked machines (`halo node`)
2
+
3
+ Let a workspace's agents work on files on **your own computer** — a laptop, a desktop, a box behind NAT — while the agents themselves keep running on the halo server. You run `halo node` on that machine; it dials out to the server (no port to open), and the workspace's agents get `node_*` tools that read, write, search and (if you allow it) run commands there.
4
+
5
+ ```
6
+ [your laptop] [server]
7
+ halo node --root ~/proj ──WebSocket──────▶ halo server ── agent in workspace W
8
+ (files, optional shell) node_read / node_write / … / node_run
9
+ ```
10
+
11
+ The machine needs only the `halo` package — no `halo setup`, no model keys, nothing under `~/.halo/`.
12
+
13
+ Supported: **macOS and Linux**. Windows works on a best-effort basis (not regularly tested; `node_run` commands go to `cmd.exe`).
14
+
15
+ ## 1. Create a token
16
+
17
+ Admin → **Channels → Linked machines** → **Add machine**. Pick the workspace and a machine name (`a-z 0-9 -`, e.g. `mbp`; unique within the workspace — the agent uses it to address the machine). The dialog shows a command to copy:
18
+
19
+ ```bash
20
+ HALO_NODE_TOKEN=<token> halo node --server https://halo.example.com --root <folder>
21
+ ```
22
+
23
+ The token stays on the list row (copy button). Deleting the row revokes it at once and disconnects the machine.
24
+
25
+ ## 2. Run `halo node` on the machine
26
+
27
+ ```bash
28
+ npm install -g @turmind/halo # once
29
+ export HALO_NODE_TOKEN=<token> # better than --token: flags show up in ps / shell history
30
+ halo node --server https://halo.example.com --root ~/proj
31
+ ```
32
+
33
+ It runs in the foreground and logs each call (`[Node] read src/app.ts`, `[Node] refused write ../x: outside root`). Ctrl-C stops it. It reconnects by itself after network drops or a server restart; it exits on a bad / revoked token or when the workspace is gone.
34
+
35
+ The admin row turns green (live, no refresh) and shows `folder · shell on/off · os`.
36
+
37
+ ### Folder mode vs whole-machine mode
38
+
39
+ - **`--root <folder>`** — share one folder. Every path the agent passes must stay inside it (symlinks pointing out are refused too); relative paths start from it.
40
+ - **No `--root`** (or `--root /`) — **whole-machine mode**: the agent can reach every path your user can, like a full-access local session. Relative paths start from the directory you ran `halo node` in. A startup line warns about it, and the admin shows "Whole machine".
41
+
42
+ Prefer `--root` unless you really mean the whole machine.
43
+
44
+ ### `--allow-shell`
45
+
46
+ Off by default — `node_run` then refuses with "restart `halo node` with --allow-shell". With it, agents can run **any command as your user, not sandboxed**. In folder mode only the command's working directory is held inside the folder; the command itself (`cat ~/.ssh/...`, `curl ...`) can reach anything your user can. Turn it on only for agents and workspaces you trust. Commands are killed after the shell timeout (120 s, or `HALO_SHELL_TIMEOUT` in ms on the machine).
47
+
48
+ ### What the agent can see
49
+
50
+ Everything inside the shared folder — including `.env` files, keys, credentials and git history. Don't share a folder that holds secrets you wouldn't paste into the chat. Whole-machine mode exposes your whole home directory.
51
+
52
+ ### Behind a login proxy
53
+
54
+ `halo node` sends a bearer token, nothing else. If the server sits behind an auth proxy (Cloudflare Access, an SSO gateway, basic auth), either point `--server` at an address the machine can reach directly, or pass the proxy's credentials with `--header` (repeatable, `Name: value`, split on the first colon):
55
+
56
+ ```bash
57
+ halo node --server https://halo.example.com --root ~/proj \
58
+ --header "CF-Access-Client-Id: <id>" --header "CF-Access-Client-Secret: <secret>"
59
+ ```
60
+
61
+ ## 3. Let the agent use it
62
+
63
+ Node tools are enabled per agent by **`node_list`** in `agent.yaml`'s `tools:` — that one name brings all eight (`node_list`, `node_read`, `node_write`, `node_edit`, `node_list_dir`, `node_grep`, `node_glob`, `node_run`). The bundled `default` agent lists it. If your workspace overrides the default agent (`<workspace>/.halo/agents/default/agent.yaml`), or you use another agent, add it yourself:
64
+
65
+ ```yaml
66
+ tools:
67
+ - file_read
68
+ # …
69
+ - node_list # linked machines (whole node_* set)
70
+ ```
71
+
72
+ Only **Full** access sessions get node tools — a Workspace or Readonly session listing `node_list` gets none. Cron jobs in the workspace can use them too.
73
+
74
+ Then just ask: "list my machines", "read `notes/todo.md` on mbp", "run the tests on mbp". The agent calls `node_list` first to see which machines are online.
75
+
76
+ ## Troubleshooting
77
+
78
+ | Message | Meaning |
79
+ |---|---|
80
+ | `token rejected by the server (401)` | Wrong / deleted token — create a new one. 5 bad tries in 15 min lock the address out for a while (429). |
81
+ | `the workspace this token is bound to no longer exists (404)` | The workspace folder was removed on the server. |
82
+ | `protocol mismatch (426)` | `halo` versions too far apart — upgrade the older side. |
83
+ | `this token is already connected from another process` | Another `halo node` with the same token is running; this one keeps retrying until that one stops. Also normal right after the machine rebooted or woke from sleep: the server needs 40–60 s to notice the old connection is dead, so the new process backs off and can take up to ~90 s to come online. |
84
+ | agent: `machine "mbp" is offline` | `halo node` isn't running / connected. |
85
+ | agent: `went offline during the call` | The connection dropped mid-call; the operation may or may not have happened. Running commands on the machine were stopped. |
86
+ | agent: `not available in this runtime` | You're in the standalone CLI / TUI — linked machines only work through `halo server` (admin UI or a channel). |
@@ -102,7 +102,8 @@ Bottom-panel Terminal tab:
102
102
  - xterm.js frontend + node-pty backend
103
103
  - Multi-instance (each terminal owns its own PTY), listed as a collapsible vertical tab strip on the right
104
104
  - cwd = current project root
105
- - Reconnect grace: 5 min (`timeout.terminal_grace`)
105
+ - Terminals are shared by every browser / device that opens the workspace and kept until closed with ✕ (or the shell exits, or the server restarts): reconnecting, refreshing or coming back days later restores the full screen — scrollback, and vim / htop as they were
106
+ - Local echo: what you type shows at once (dimmed) and turns normal when the server echoes it back, so a slow link doesn't lag typing; vim / nano / tmux and other full-screen programs are not predicted
106
107
 
107
108
  ## Settings
108
109
 
@@ -140,12 +141,14 @@ See [requirements/settings.md](../requirements/settings.md), [secrets-and-creden
140
141
  | `Cmd/Ctrl + P` | Quick Open |
141
142
  | `Cmd/Ctrl + S` | Save file |
142
143
  | `Alt + W` | Close editor tab (browser; the desktop app uses `Cmd/Ctrl + W`) |
143
- | `Cmd/Ctrl + \`` | Switch the bottom panel between Chat and Terminal |
144
+ | `Cmd/Ctrl + \`` | Switch the bottom panel between Chat and Terminal (no-op in split view) |
144
145
  | `Esc` | Leave editor maximize |
145
146
 
146
147
  ## Bottom panel switching
147
148
 
148
- Two tabs in the header: Chat / Terminal, plus maximize and float / dock buttons. The panel opens on Chat after a refresh.
149
+ Two tabs in the header: Chat / Terminal, plus split, maximize and float / dock buttons. The panel opens on Chat after a refresh, unless split view is on.
150
+
151
+ **Split view** shows Chat and Terminal at once — side by side, or stacked when the panel is narrow (e.g. floating); drag the divider to resize. It stays on across refreshes; click either tab to go back to a single view.
149
152
 
150
153
  ## Login / logout
151
154
 
@@ -60,7 +60,7 @@ Same id present in both scopes: workspace wins; the overridden global is greyed
60
60
 
61
61
  ### Tool selection
62
62
  - **Session tools**: `start_session` / `session_list` / `query_session` / `interrupt_session` / `stop_session` / `archive_session` / `get_session_output` / `query_agent` — **not** selected by name; the whole bundle is granted automatically by a non-empty `team` (see below). Listing them under `agent.yaml tools` has no effect.
63
- - **Workspace tools**: `file_read` / `view_image` / `file_write` / `file_edit` / `file_list` / `shell_exec` / `grep` / `glob` / `web_fetch`, plus the single `relay_send` chip (grants the whole cross-workspace relay set, full-access sessions only) and the single `a2a_send` chip (grants the cross-server A2A set — `a2a_send` / `a2a_stop` / `a2a_read` / `a2a_list` — at every access level except the A2A read-only profile), returned by `GET /api/agent-configs/tools`. `view_image` is dropped at runtime when the model can't take images (the form dims it)
63
+ - **Workspace tools**: `file_read` / `view_image` / `file_write` / `file_edit` / `file_list` / `shell_exec` / `grep` / `glob` / `web_fetch`, plus the single `relay_send` chip (grants the whole cross-workspace relay set, full-access sessions only) the single `a2a_send` chip (grants the cross-server A2A set — `a2a_send` / `a2a_stop` / `a2a_read` / `a2a_list` — at every access level except the A2A read-only profile) and the single `node_list` chip (grants the eight linked-machine `node_*` tools, full-access sessions only — see [design/node.md](../design/node.md)), returned by `GET /api/agent-configs/tools`. `view_image` is dropped at runtime when the model can't take images (the form dims it)
64
64
  - **`activate_skill`**: auto-injected when the YAML lists at least one usable skill (not disabled, allowed at the session's access level); loads the full SKILL.md on demand
65
65
 
66
66
  ### Team (delegation switch + whitelist)
@@ -22,25 +22,32 @@ xterm.js terminal with a node-pty backend, multi-tab, reconnect-resilient.
22
22
 
23
23
  Derivation in `createTerminal` in [packages/admin/src/features/terminal/terminal-panel.tsx](../../../packages/admin/src/features/terminal/terminal-panel.tsx); backend resolution in `TerminalManager.start` in [packages/server/src/ws/terminal-manager.ts](../../../packages/server/src/ws/terminal-manager.ts).
24
24
 
25
- Workspace switch reloads the page and does **not** migrate existing terminals — they keep their original cwd and are only reattached in their own workspace (within the grace period); the new workspace starts a fresh terminal at its root.
25
+ Workspace switch reloads the page and does **not** migrate existing terminals — they keep their original cwd and are reattached only in their own workspace; the new workspace shows its own terminals, or starts a fresh one at its root if it has none.
26
26
 
27
- ### Reconnect resilience
27
+ ### Shared, persistent terminals
28
+
29
+ A workspace's terminals belong to the workspace, not to a browser: the admin has one login, so every page that opens the workspace — another tab, another browser, another device — sees and drives the same terminals.
28
30
 
29
- When the WebSocket drops:
31
+ - **Persistence**: a PTY ends only when it is closed with ✕, when its shell exits, or when the server restarts. Closing the browser, losing the connection, or leaving for days doesn't end it — there is no grace timer.
32
+ - **Full restore**: the server mirrors each PTY into a headless xterm (`@xterm/headless`). A page that attaches gets a serialized snapshot of it (`@xterm/addon-serialize`): the scrollback (`general.limits.terminal_scrollback_lines`, default 5000 lines), the screen, a full-screen program's alternate screen (vim, htop), and the terminal modes — bracketed paste, application cursor keys, mouse tracking and SGR mouse encoding, hidden cursor. Output made while nobody was attached is part of the snapshot.
33
+ - **Live sync**: every attached page receives the output and can type; all input goes to the one PTY.
34
+ - **Close everywhere**: ✕ on any page sends `terminal:close` and the PTY ends; every attached page gets `terminal:exit` and prints `[Process exited]`.
35
+ - **Size**: the PTY follows its *driver*, the page that last typed into it. Each page reports its size (`terminal:resize`); when a page types and its size differs from the PTY's, the PTY (and the mirror) switch to that size. A resize from a page that isn't the driver is only recorded, unless no driver is attached any more — then it applies at once. Pages with a different size than the driver see the driver's layout reflowed into their own width.
36
+
37
+ ### Reconnect resilience
30
38
 
31
- 1. PTYs are **not killed** — they stay in the module-level `terminals` map with `currentWs = null` (`packages/server/src/ws/terminal-manager.ts`)
32
- 2. Output during detach is buffered (ring buffer, up to `config.limits.terminalOutputBuffer` = 50 KB per terminal)
33
- 3. Grace period: `config.timeout.terminalGrace` (default 5 min)
34
- 4. On reconnect, the client sends `terminal:reattach` with its `browserId` and workspace path (sent on initial mount **and** on every subsequent `_connected` event); only PTYs of that browser × workspace are claimed
35
- 5. Server replays the entire output buffer, reattaches live I/O, and responds with `terminal:reattached { terminalIds: [...] }`
36
- 6. If the grace timer expires first, the PTY is killed and the detach entry removed
39
+ 1. On mount (once the workspace is known — with split view on, the panel mounts before the page has resolved its workspace) **and** on every `_connected`, the panel sends `terminal:reattach { workspacePath }`. The server attaches the connection to every PTY of that workspace and subscribes it to the workspace
40
+ 2. The server answers `terminal:reattached { terminals: [{ id, name }] }`, then one `terminal:snapshot` per PTY, then live `terminal:output`. Snapshot and live stream join exactly: output that arrives while a snapshot is being taken is queued for that page and sent right after it — nothing lost, nothing repeated
41
+ 3. A PTY another page starts later is pushed to every subscribed page as `terminal:opened { terminalId, name }` + its snapshot — no polling
42
+ 4. When a connection closes, the server only removes it from the PTYs' attached sets and from the workspace subscription; no timer is started
37
43
 
38
44
  Connection-level liveness and reconnect (server keepalive tolerance, client self-check timer, auth-expiry handling) are owned by the shared WS client — see [design/ws.md](../design/ws.md#client-side-liveness--reconnect).
39
45
 
40
- When the WS drops, the bottom panel remounts `TerminalPanel` (fresh key on `_disconnected`): local xterm instances are disposed without sending `terminal:close`, and the new panel reattaches like a page load. The local scrollback is lost; the server replays only what was buffered while detached. On the reattach handler ([packages/admin/src/features/terminal/terminal-panel.tsx](../../../packages/admin/src/features/terminal/terminal-panel.tsx)), each id in `terminalIds` is dispatched by whether a local xterm instance already exists:
46
+ When the WS drops, the bottom panel remounts `TerminalPanel` (fresh key on `_disconnected`): local xterm instances are disposed without sending `terminal:close`, and the new panel reattaches like a page load — its screen comes back from the snapshots. In the panel ([packages/admin/src/features/terminal/terminal-panel.tsx](../../../packages/admin/src/features/terminal/terminal-panel.tsx)):
41
47
 
42
- - **Already exists** (a reattach without a remount): only a `terminal:resize` is sent so server PTY dimensions resync; the existing instance keeps its scrollback and continues receiving live output.
43
- - **Does not exist** (first mount, or after the remount above): a fresh xterm container is created and bound to that id. Bracketed paste mode is resynced by locally writing `\x1b[?2004h` into the new instance — bash enabled the mode on the PTY at spawn time, but that sequence went to the disposed instance; without the resync, a multi-line paste into the reattached terminal would be sent unbracketed and execute line by line.
48
+ - `terminal:reattached` / `terminal:opened`: a tab is added for each id not shown yet, named as the server says (the name the creating page gave in `terminal:start`).
49
+ - `terminal:snapshot`: the local xterm (created if missing) is `reset()`, sized to the snapshot's `cols × rows`, given the snapshot, then fit to its container; the fitted size is reported with `terminal:resize`.
50
+ - If 2 s after the connection is up the panel still has no terminal, it starts one (the timer is armed only while connected, so an outage doesn't add a PTY per remount).
44
51
 
45
52
  ### Environment
46
53
  - Shell: `$SHELL` or `/bin/bash` (`ComSpec` / `powershell.exe` on Windows); bash / zsh / sh / fish start as login shells (`-l`)
@@ -61,45 +68,59 @@ Keys depend on the client's OS (the keyboard in front of the user — the PTY is
61
68
  - **Right-click menu** (inside the terminal): Copy (disabled with no selection) · Paste · Select all · Clear. Menu copy / paste use the async Clipboard API; when paste is refused (plain-http origin or no clipboard-read permission) the menu says to use the keyboard shortcut instead
62
69
  - Key mapping: `terminalClipboardKey` in [packages/admin/src/features/terminal/terminal-clipboard.ts](../../../packages/admin/src/features/terminal/terminal-clipboard.ts); menu in `terminal-context-menu.tsx`
63
70
 
71
+ ### Local echo (predictive)
72
+ Keystrokes show up before the server's echo comes back, so typing over a slow link doesn't lag. This is a port of VS Code's terminal type-ahead ([packages/admin/src/features/terminal/type-ahead/](../../../packages/admin/src/features/terminal/type-ahead/)), and it is always on. No setting turns it off.
73
+
74
+ - A predicted character is drawn **dim**. When the PTY echoes the same character, it is redrawn normally. When the PTY sends something else, the prediction is rolled back: the original cell and cursor come back, then the real output is written. This happens, for example, when another page typed into the same terminal first (see Shared, persistent terminals). No residue is left.
75
+ - Predicted keys: printable characters, Backspace, ←/→ (also `Esc b` / `Esc f`), and Enter when the cursor isn't on the last row. Any other key ends prediction until the PTY answers: Tab, ↑/↓, Ctrl-keys and the like. This is why **Tab completion and history paging are not predicted**.
76
+ - The first keystroke on a line is drawn only after the PTY has echoed one. A prompt that doesn't echo starts nothing.
77
+ - Not predicted:
78
+ - while the title names `vim`, `vi`, `nano` or `tmux`;
79
+ - in any full-screen program on the alternate screen (htop, less in full-screen mode…);
80
+ - at a `less`-style `:` prompt on the bottom row.
81
+ - A prediction the PTY never echoes is withdrawn after a timeout: at least 500 ms, or 1.5 × the slowest confirmed echo, whichever is longer. **Password prompts**: if the prompt is on a line where echo was already confirmed, a typed character may flash dim and then be withdrawn. At a fresh prompt nothing is drawn.
82
+ - A `terminal:snapshot` drops all pending predictions without rolling them back, because the snapshot replaces the whole screen.
83
+
64
84
  ### Lifecycle
65
85
 
66
86
  ```
67
- ┌─────────────┐
68
- create ──▶│ in this WS │
69
- │ session map │
70
- └──────┬──────┘
71
- │ WS drops
72
- ▼
73
- ┌─────────────┐
74
- │ detached │ (grace timer = 5min, ring buffer active)
75
- │ pool │
76
- └──┬───────┬──┘
77
- reattach │ │ grace expires
78
- ▼ ▼
79
- ┌─────────┐ kill PTY, clean up
80
- │ replay +│
81
- │ live IO │
82
- └─────────┘
87
+ terminal:start
88
+ │
89
+ ▼
90
+ ┌──────────────────────────────────┐ page attaches
91
+ │ PTY + headless mirror │◀── (reattach / opened):
92
+ │ attached pages: 0..n │ snapshot, then live
93
+ │ (output + exit go to all) │
94
+ └──┬──────────────┬─────────────┬──┘
95
+ │ page leaves │ ✕ / shell │ server
96
+ │ (WS drops) │ exits │ restart
97
+ ▼ ▼ ▼
98
+ still running exit to all gone
99
+ (0 pages ok, pages, PTY
100
+ no timer) removed
83
101
  ```
84
102
 
85
103
  Close semantics:
86
- - Explicit `terminal:close` — PTY killed immediately, detach entry (if any) cleaned up
87
- - WS drop → grace expires (default 5 min) without reattach → PTY killed
104
+ - Explicit `terminal:close` (✕ on any page) — PTY killed immediately; every attached page gets `terminal:exit`
105
+ - The shell exits by itself — every attached page gets `terminal:exit` with its exit code
88
106
  - Server shutdown — all PTYs die with the process
107
+ - WS drop / browser closed — **not** an end: the PTY keeps running with no page attached, for as long as it takes
89
108
 
90
109
  ## WebSocket protocol
91
110
 
92
111
  | Direction | Type | Fields | Purpose |
93
112
  |---|---|---|---|
94
- | C→S | `terminal:start` | `terminalId?`, `cwd?`, `cols?`, `rows?` | Spawn a new PTY |
95
- | C→S | `terminal:input` | `terminalId`, `data` | Send user keystrokes |
96
- | C→S | `terminal:resize` | `terminalId`, `cols`, `rows` | Resize PTY (screen resize) |
97
- | C→S | `terminal:close` | `terminalId` | Explicit close |
98
- | C→S | `terminal:reattach` | — | Reattach all detached terminals after reconnect |
99
- | S→C | `terminal:ready` | `terminalId` | PTY spawned and ready |
100
- | S→C | `terminal:output` | `terminalId`, `data` | PTY stdout/stderr |
101
- | S→C | `terminal:exit` | `terminalId`, `exitCode` | PTY exited |
102
- | S→C | `terminal:reattached` | `terminalIds` | Reattach completed for these |
113
+ | C→S | `terminal:start` | `terminalId?`, `cwd?`, `cols?`, `rows?`, `workspacePath?`, `name?` | Spawn a new PTY in the workspace; `name` is the tab name every page shows. Pushed to the workspace's other subscribers |
114
+ | C→S | `terminal:input` | `terminalId`, `data` | Send user keystrokes; makes the sender the PTY's size driver. Rejected without `terminalId` |
115
+ | C→S | `terminal:resize` | `terminalId`, `cols`, `rows` | Report this page's size; applied if it is the driver or no driver is attached. Rejected without `terminalId` |
116
+ | C→S | `terminal:close` | `terminalId` | Explicit close — ends the PTY for every page |
117
+ | C→S | `terminal:reattach` | `workspacePath` | Attach to every PTY of the workspace and subscribe to it |
118
+ | S→C | `terminal:ready` | `terminalId` | PTY spawned and ready (to its creator) |
119
+ | S→C | `terminal:output` | `terminalId`, `data` | PTY stdout/stderr, to every attached page |
120
+ | S→C | `terminal:exit` | `terminalId`, `exitCode` | PTY ended (close or shell exit), to every attached page |
121
+ | S→C | `terminal:reattached` | `terminals: [{ id, name }]` | The workspace's PTYs, all now attached; a snapshot follows for each |
122
+ | S→C | `terminal:opened` | `terminalId`, `name` | Another page started a PTY in a subscribed workspace; a snapshot follows |
123
+ | S→C | `terminal:snapshot` | `terminalId`, `data`, `cols`, `rows` | Serialized scrollback + screen + modes at attach time, taken at `cols × rows`; live output continues right after it |
103
124
 
104
125
  Source: [packages/server/src/ws/terminal-manager.ts](../../../packages/server/src/ws/terminal-manager.ts).
105
126
 
@@ -107,8 +128,9 @@ Source: [packages/server/src/ws/terminal-manager.ts](../../../packages/server/sr
107
128
 
108
129
  | Config key | Default | Purpose |
109
130
  |---|---|---|
110
- | `config.timeout.terminalGrace` | 300,000 ms | Detach retention period |
111
- | `config.limits.terminalOutputBuffer` (setting `general.limits.terminal_scrollback_bytes`) | 50,000 bytes | Detach output ring buffer cap |
131
+ | `config.limits.terminalScrollbackLines` (setting `general.limits.terminal_scrollback_lines`) | 5000 lines | Scrollback the server-side mirror keeps per terminal (restored on attach); applies to terminals opened after a change |
132
+
133
+ `timeout.terminal_grace` / `HALO_TERMINAL_GRACE` and `general.limits.terminal_scrollback_bytes` are gone; a value left in an existing `config.yaml` / `settings.yaml` is ignored.
112
134
 
113
135
  Defined in [packages/server/src/config.ts](../../../packages/server/src/config.ts).
114
136
 
@@ -119,17 +141,27 @@ Defined in [packages/server/src/config.ts](../../../packages/server/src/config.t
119
141
  | T1 | Start, run `ls` | Output arrives; prompt returns |
120
142
  | T2 | Explorer → right-click a folder → Open in Integrated Terminal | A new terminal whose cwd is that folder (`pwd` confirms) |
121
143
  | T3 | Resize window | PTY cols/rows update; long-running process (e.g. `watch ls`) reflows |
122
- | T4 | Disconnect mid-command (`sleep 5 && echo done`) → reconnect within grace period | Buffered output replayed; `done` visible |
123
- | T5 | Disconnect → wait > grace period → reconnect | Terminal gone (PTY killed at grace expiry) |
144
+ | T4 | Disconnect mid-command (`sleep 5 && echo done`) → reconnect | Screen restored from the snapshot: scrollback from before the drop plus `done` |
145
+ | T5 | Disconnect (or close the browser) → come back hours later | The terminal is still there, scrollback and running programs intact |
146
+ | T5b | Run `vim` (or `htop`) → refresh the page | vim's screen is back as it was; keys and mouse work in it; after `:q` the shell's scrollback is back |
147
+ | T5c | Open the same workspace in two browsers | Both show the same terminals; typing in either shows on both; ✕ in one prints `[Process exited]` in both |
148
+ | T5d | Open a terminal in browser A while B has the workspace open | B gets the new tab with A's name, live |
149
+ | T5e | Open the workspace on another device | Same terminals with their full scrollback; another workspace's terminals don't appear |
150
+ | T5f | Two browsers with different window sizes, type in the narrow one, then in the wide one | `stty size` follows whoever typed last |
124
151
  | T6 | Open 3 tabs, close 1 explicitly | Other 2 keep their PTYs; closed one gets `terminal:exit` |
125
152
  | T7 | `exit` from within shell | `terminal:exit` with the shell's exit code; the terminal prints `[Process exited]` and its tab stays until closed |
126
153
  | T8 | Paste a 10 KB block | Sent as `terminal:input` without choking; shell echoes in chunks |
127
154
  | T9 | Windows/Linux: select text → `Ctrl+C`; then `Ctrl+C` again with nothing selected | First copies and clears the selection; second sends `^C` (interrupts a running `sleep 30`) |
128
155
  | T10 | Right-click in the terminal → Paste | Clipboard text inserted at the prompt (or the "use the shortcut" hint on a plain-http origin) |
156
+ | T11 | Over a slow link (e.g. 300 ms RTT), type `ls -la` after a first confirmed character | Characters appear at once, dim. They turn normal as the echo arrives. Backspace and ←/→ are instant too |
157
+ | T12 | Two browsers on one terminal, type different characters in both at the same moment | Each page ends up showing exactly what the shell echoed. A dim prediction from the losing page is rolled back with no stray character left |
158
+ | T13 | Type in `vim`, `nano`, `tmux`, `htop`, `less` | Nothing is drawn ahead of the program's own output |
159
+ | T14 | `sudo -k; sudo true`, then type the password | Nothing stays on screen. At most a dim character flashes and is withdrawn within about 0.5 s |
160
+ | T15 | Type, then Tab to complete or ↑ for history | The completion or history entry appears only when the shell sends it. The characters typed before it are kept |
129
161
 
130
162
  Follows the pattern of [test/session.md](../test/session.md).
131
163
 
132
164
  ## Related design
133
165
 
134
- - Detach / reattach plumbing: [design/architecture.md#terminalmanager](../design/architecture.md#terminalmanager--pty-management)
166
+ - Attach / snapshot plumbing: [design/architecture.md#terminalmanager](../design/architecture.md#terminalmanager--pty-management)
135
167
  - WS envelope shape: [design/ws.md](../design/ws.md)