@turmind/halo 1.1.8 → 1.1.9

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 (57) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/133-e2df20fc6362a02b.js +1 -0
  3. package/admin-out/_next/static/chunks/200.dd28be896c7765de.js +1 -0
  4. package/admin-out/_next/static/chunks/442.14eec8e615cc727b.js +1 -0
  5. package/admin-out/_next/static/chunks/515.b793a7455c5e8342.js +1 -0
  6. package/admin-out/_next/static/chunks/525.fe35ab8501121729.js +1 -0
  7. package/admin-out/_next/static/chunks/579.3ab0b2bb9244fc37.js +1 -0
  8. package/admin-out/_next/static/chunks/65.3aac567c42cec99c.js +1 -0
  9. package/admin-out/_next/static/chunks/896.2069ab525ef81643.js +1 -0
  10. package/admin-out/_next/static/chunks/935.91ddb965b44041c9.js +1 -0
  11. package/admin-out/_next/static/chunks/app/{layout-c2fa8035998e48d5.js → layout-e75df4cae90cf325.js} +1 -1
  12. package/admin-out/_next/static/chunks/app/page-ca15438e8bddb403.js +1 -0
  13. package/admin-out/_next/static/chunks/webpack-f80c9b8cca4447a3.js +1 -0
  14. package/admin-out/_next/static/css/{0d0ca7b3c8cc5562.css → f96c61f28fab6941.css} +1 -1
  15. package/admin-out/index.html +1 -1
  16. package/admin-out/index.txt +5 -5
  17. package/bundled-docs/design/architecture.md +2 -2
  18. package/bundled-docs/design/background-dispatch.md +1 -1
  19. package/bundled-docs/design/cron.md +1 -1
  20. package/bundled-docs/design/feishu.md +2 -2
  21. package/bundled-docs/design/halo-city.md +3 -1
  22. package/bundled-docs/design/prompt-system.md +12 -13
  23. package/bundled-docs/design/session.md +10 -3
  24. package/bundled-docs/design/web.md +12 -2
  25. package/bundled-docs/design/ws.md +3 -3
  26. package/bundled-docs/dev/acp-adapter.md +5 -5
  27. package/bundled-docs/dev/api.md +5 -4
  28. package/bundled-docs/dev/deploy.md +1 -1
  29. package/bundled-docs/dev/desktop-packaging.md +8 -4
  30. package/bundled-docs/dev/env.md +1 -1
  31. package/bundled-docs/dev/tools.md +16 -6
  32. package/bundled-docs/guide/channels/acp.md +3 -3
  33. package/bundled-docs/requirements/settings.md +2 -2
  34. package/dist/index.js +6622 -6403
  35. package/package.json +1 -1
  36. package/templates/agents/__evo_agent__/AGENT.md +13 -38
  37. package/templates/agents/goal/AGENT.md +29 -11
  38. package/templates/models/deepseek.yaml +6 -2
  39. package/templates/prompts/all/RUNTIME.md +16 -0
  40. package/templates/prompts/root/WORKSPACE_MEMORY.md +3 -0
  41. package/templates/skills/aws-knowledge/SKILL.md +3 -0
  42. package/templates/skills/cron/SKILL.md +14 -12
  43. package/templates/skills/halo/SKILL.md +1 -1
  44. package/templates/skills/web-search/SKILL.md +4 -1
  45. package/admin-out/_next/static/chunks/200.3b8f8a74f005a134.js +0 -1
  46. package/admin-out/_next/static/chunks/212-70bd2971392bde84.js +0 -1
  47. package/admin-out/_next/static/chunks/442.68ed26e27eb1d84f.js +0 -1
  48. package/admin-out/_next/static/chunks/515.4d98fae302278913.js +0 -1
  49. package/admin-out/_next/static/chunks/525.2c10a4fd329d0d24.js +0 -1
  50. package/admin-out/_next/static/chunks/579.a2718ddd395a2f3a.js +0 -1
  51. package/admin-out/_next/static/chunks/65.859665ba6717967a.js +0 -1
  52. package/admin-out/_next/static/chunks/896.ed21ec1e0846d20d.js +0 -1
  53. package/admin-out/_next/static/chunks/935.543aeec8456b6061.js +0 -1
  54. package/admin-out/_next/static/chunks/app/page-71c576815b4a30d9.js +0 -1
  55. package/admin-out/_next/static/chunks/webpack-fc1574554e7b1108.js +0 -1
  56. /package/admin-out/_next/static/{u4Su_gs1Pb6p_XeR45rCm → nVEORygQbBeOf2cFl82FW}/_buildManifest.js +0 -0
  57. /package/admin-out/_next/static/{u4Su_gs1Pb6p_XeR45rCm → nVEORygQbBeOf2cFl82FW}/_ssgManifest.js +0 -0
@@ -92,7 +92,7 @@ State: `messages: AnthropicMessage[]` (the full conversation history; external c
92
92
 
93
93
  File: `ws/handler.ts`. See [design/ws.md](ws.md).
94
94
 
95
- One `ConnectedClient` per WS connection, holding: `sessionManager` / `agentSessionId` / `terminalManager` / `fileWatcher` / `backgroundSaves`. UI state (messageLog / streamBuffer / tokens) belongs to SessionManager's `UIState`, not the client.
95
+ One `ConnectedClient` per WS connection, holding: `sessionManager` / `agentSessionId` / `terminalManager` / `backgroundSaves`. UI state (messageLog / streamBuffer / tokens) belongs to SessionManager's `UIState`, not the client. File watchers live in a per-workspace `WatcherPool` (`ws/watcher-pool.ts`) shared by every connection on that workspace.
96
96
 
97
97
  Message dispatch:
98
98
  - `chat` / `chat:stop` / `subscribe` / `session:clear` / `session:delete` → handled directly in `ws/handler.ts`
@@ -162,7 +162,7 @@ File: `tools/workspace-tools.ts`. 9 tools: file_read / view_image / file_write /
162
162
  File: `ws/handler.ts` (`cleanupConnection`) + `bufferDetachedNotification` in `ws/event-processor.ts`. After WS disconnect, an inline handler takes over the session tree's events and buffers structural ones for replay on reconnect. A *cleared* session (`/session new`) gets no handler at all — see [design/background-dispatch.md](background-dispatch.md).
163
163
 
164
164
  ### WorkspaceWatcher — file watching
165
- File: `ws/file-watcher.ts`. chokidar → 300ms debounce + per-path Map dedup → callback. Ignores node_modules / .git / .next / dist. `.halo/sessions/` and `.halo/logs/` are **not** ignored — the dedup keeps volume low, and the front-end drops `change` events for files not open in the editor, so Explorer can reflect session deletions/creations while chat streaming stays cheap.
165
+ File: `ws/file-watcher.ts`. `@parcel/watcher` recursive native subscription (one per workspace root, shared across connections via `ws/watcher-pool.ts`) → 300ms debounce + per-path Map dedup → callback. Ignores node_modules / .git / .next / dist. `.halo/sessions/` and `.halo/logs/` are **not** ignored — the dedup keeps volume low, and the front-end drops `change` events for files not open in the editor, so Explorer can reflect session deletions/creations while chat streaming stays cheap.
166
166
 
167
167
  ### TerminalManager — PTY management
168
168
  File: `ws/terminal-manager.ts`. Spawns a shell via node-pty. On disconnect, detaches with a 50KB ring buffer; replays on reconnect.
@@ -106,7 +106,7 @@ cleanupConnection
106
106
  ├─ flush every backgroundSaves
107
107
  │ for (const [sid, saveFn] of backgroundSaves)
108
108
  │ saveFn()
109
- └─ stop fileWatcher + gitDirWatcher
109
+ └─ watchers.detach(ws) (shared per-workspace watchers stop only when the last socket on that workspace leaves)
110
110
  ```
111
111
 
112
112
  ## Relevant files
@@ -52,7 +52,7 @@ Each fire:
52
52
  1. **Concurrency guard**: if the same `jobId` is already running in this process (in-memory `_inflight` set), insert a `cron_runs` row with `status='skipped'`, `failureReason='previous run still in progress'`, broadcast, and bail without spawning. Applies to both scheduled fires and manual run-now clicks. Two overlapping cli children would double-write the same `cron-<jobId>` on-disk session state; SessionManager's per-session lock is in-process and can't see across cli children, so this set is the cheapest place to enforce serialization. The set itself is in-memory, but its restart blind spot is covered: the boot-time orphan sweep (below) re-enters stale jobIds before croner re-arms.
53
53
  2. Insert a `cron_runs` row with `status='running'` up front (UI sees it mid-flight); broadcast `cron:run_changed`.
54
54
  3. Check `job.workspacePath` exists — fail loudly if the workspace was moved/deleted.
55
- 4. Spawn `halo cli -a <agent> -s cron-<jobId> -w <workspace>`, prompt on **stdin** (avoids Windows argv length limits). The stable `cron-<jobId>` session id means each fire *resumes the same session*, so the conversation accumulates and is reviewable in the admin Sessions tab. The child's pid is written back onto the `running` row (`cron_runs.pid`, nullable) — this is what makes the orphan sweep possible after a server crash. Note the **dual-writer layout** this creates: the cli child and the server share the workspace's session files. The server side is view-only for these sessions — admin subscribes seed a UIState from disk, and the WS detach-save skips it via the dirty gate (see [session.md](session.md#ws-disconnect-resilience)); without that gate a frozen server snapshot would overwrite the cli child's newer messages (the cron-session UI-log truncation incident). Live streaming of a cli-driven session into the admin is out of scope — events don't cross processes; the Sessions tab re-reads the file on open.
55
+ 4. Spawn `halo cli -a <agent> -s cron-<jobId> -w <workspace>`, prompt on **stdin** (avoids Windows argv length limits), with a fixed `[Unattended cron run — nobody will answer questions …]` line stamped ahead of `user_prompt` on every fire (`UNATTENDED_PREAMBLE` in `runner.ts`) — the model can't see the `cron-*` session id, and prompts typed into the admin Cron form carry no such notice, so the runner adds it rather than trusting the prompt author. The stable `cron-<jobId>` session id means each fire *resumes the same session*, so the conversation accumulates and is reviewable in the admin Sessions tab. The child's pid is written back onto the `running` row (`cron_runs.pid`, nullable) — this is what makes the orphan sweep possible after a server crash. Note the **dual-writer layout** this creates: the cli child and the server share the workspace's session files. The server side is view-only for these sessions — admin subscribes seed a UIState from disk, and the WS detach-save skips it via the dirty gate (see [session.md](session.md#ws-disconnect-resilience)); without that gate a frozen server snapshot would overwrite the cli child's newer messages (the cron-session UI-log truncation incident). Live streaming of a cli-driven session into the admin is out of scope — events don't cross processes; the Sessions tab re-reads the file on open.
56
56
  5. Tee stdout + stderr live to `~/.halo/global/logs/cron/<runId>.log`; capture stdout to memory for dispatch. Since 1.1.5 the cli's stdout is the **final reply of the last root turn** as raw markdown (stdout is not a TTY, so no terminal styling; the interim text the model emits before tool calls and the wrap-ups of earlier drained turns are not included — see [session.md](session.md#message-queue-and-drain) "Final-text flag"). Before that it was every streamed text block of the whole run, which for a director session absorbing a dozen sub-agent reports reached ~40 KB and blew the wechat gateway ceiling.
57
57
  6. Enforce a **per-job timeout** (`job.timeoutSec ?? 3600`, the nullable `cron_jobs.timeout_sec` validated 60–21600 at every write surface; a change takes effect on the job's next fire, no croner re-instantiation needed) via a Node timer (cross-platform, not the Linux-only `timeout(1)`), with a **two-phase kill**: SIGTERM the direct child first — the cli has a SIGTERM handler that stops the harness, repairs + flushes the session, kills its own shell_exec process groups, and exits 143 — then, if it's still alive after a 30s grace, SIGKILL the entire descendant tree (POSIX ppid-walk over one `ps` snapshot; `taskkill /T /F` on Windows) so no grandchild survives to double-write the workspace on the next fire. A ppid walk, not a process-group kill: the cli sandbox spawns shell_exec workers `detached` into their own groups, so `kill(-pid)` would miss them. Timed-out runs still report exit code 124. Generous because overlap-of-the-same-job is already blocked up-front in step 1; this is just the long-stop reaper for a truly stuck child.
58
58
  7. Classify: exit 124 → `timeout`; non-zero → `failed`; zero but empty stdout → `failed` ("nothing to dispatch" — with the final-only stdout contract this means the last turn produced no wrap-up text, not that the agent never spoke); else `succeeded`.
@@ -110,7 +110,7 @@ The SDK wraps protobuf marshalling; we just register an `EventDispatcher` callba
110
110
  2. Check if bot was mentioned (p2p: always yes; group: check mentions array for bot's open_id)
111
111
  3. Drop non-user senders (bots, apps)
112
112
  4. Determine session key: p2p → `(chatId, 'dm')`; group → `(chatId, rootId ?? messageId)` to anchor each thread
113
- 5. Parse content (text / image / file / post) — images extracted as base64 for vision, files noted as `[文件: name]` markers
113
+ 5. Parse content (text / image / file / audio / media / post) — images extracted as base64 for vision; files, voice notes (`audio`) and videos (`media`) all go through `downloadResource(type: 'file')` and land under `.halo/assets/feishu/inbound/`, agent gets `[文件 "name" 已保存: path]` / `[语音消息 Ns已保存: path]` / `[视频已保存: path]` (same wording as the wechat handler, so the admin renders them identically). The video's cover `image_key` is not fetched.
114
114
  6. Download inbound images, save to workspace, generate base64 payloads
115
115
  7. Strip mention markup from text (Feishu inserts `<at user_id="ou_xxx">@bot</at>` or `@_user_123` tokens)
116
116
  8. Slash command dispatch (p2p only — group threads don't benefit from `/session new`, `/session list`, etc. since each thread is already its own session)
@@ -162,7 +162,7 @@ trailing chunks of a long reply were dropped.
162
162
 
163
163
  ## Media support
164
164
 
165
- **Inbound:** text, images (base64 attached to agent message), files (noted as `[文件: name]` markers), voice, video, rich posts.
165
+ **Inbound:** text, images (base64 attached to agent message), files / voice notes / videos (all downloaded via `downloadResource(type: 'file')`, saved under `.halo/assets/feishu/inbound/` — voice as `voice_<ts>_<rand>.opus`, video under its sender filename; agent gets `[文件 "name" 已保存: path]` / `[语音消息 Ns已保存: path]` / `[视频已保存: path]`), rich posts. No speech-to-text — the agent gets the .opus path, same as wechat/telegram.
166
166
 
167
167
  **Outbound routing:**
168
168
  - `.jpg/.png/.gif/.webp/.bmp` → `uploadImage` → `msg_type: 'image'`
@@ -113,7 +113,9 @@ js/
113
113
 
114
114
  - `GET /api/show/session?ws=&id=` — 只读会话详情:裁剪后的消息日志 + 真实
115
115
  token 上限。鉴权同 `/api/show/state`(x-token;`full`/`observer` 可跨
116
- workspace 读取,其余 accessLevel 只能看自己 workspace)
116
+ workspace 读取,其余 accessLevel 只能看自己 workspace,**且只能看自己
117
+ token 铸造的会话**(id 以 `web_<accountId>_` 开头);点其它市民会 403,
118
+ 前端在 inspector 里显示加载失败框而不是 transcript
117
119
  - `/api/show/state` — 无活跃 UIState 的会话(idle/stopped/重启后)token
118
120
  从会话文件头读取(mtime 缓存,不随轮询刷盘),不显示 0;含 `messageCount` 字段
119
121
 
@@ -16,7 +16,7 @@ Entry point: `session-manager.ts` `buildAgentInstance(agentId, sessionId, parent
16
16
  ├── INSTRUCTIONS.md ← global user preferences
17
17
  ├── prompts/ ← user-editable system prompts (externalised)
18
18
  │ ├── bootstrap/BOOTSTRAP.md ← first-run guidance
19
- │ ├── all/ ← every-agent rules (TOOL_GUIDELINES.md, TOOL_SHELL[.windows].md, WORKSPACE_CONVENTIONS.md)
19
+ │ ├── all/ ← every-agent rules (TOOL_GUIDELINES.md, TOOL_SHELL[.windows].md, WORKSPACE_CONVENTIONS.md, RUNTIME.md)
20
20
  │ └── root/ ← root-agent-only (empty by default; user-set)
21
21
  ├── agents/<id>/{agent.yaml, AGENT.md}
22
22
  └── skills/<id>/SKILL.md ← built-in `halo` skill carries platform self-knowledge (loaded on demand via activate_skill)
@@ -38,7 +38,7 @@ Entry point: `session-manager.ts` `buildAgentInstance(agentId, sessionId, parent
38
38
  └── skills/<id>/SKILL.md
39
39
  ```
40
40
 
41
- `.halo/` is listed in grep/glob `SKIP_DIRS`, so agents read via `file_read`.
41
+ `.halo/` is walked by grep/glob (only its runtime subtrees `sessions/ logs/ evo/ tmp/ assets/ canvas/` are skipped), so agents can search the knowledge base directly as well as `file_read` it.
42
42
 
43
43
  ## Step 1 — Load agent YAML
44
44
 
@@ -106,7 +106,7 @@ interface MdContents {
106
106
  [system-prompts.ts](../../../packages/server/src/prompts/system-prompts.ts) keeps the hard-coded defaults for seeding and fallback.
107
107
 
108
108
  ### Seed (init.ts startup hook)
109
- Startup seeds `templates/prompts/{bootstrap,all,root}/` into `~/.halo/global/prompts/` (currently `BOOTSTRAP.md`; `TOOL_GUIDELINES.md`, `TOOL_SHELL[.windows].md`, `WORKSPACE_CONVENTIONS.md`; `WORKSPACE_MEMORY.md`). These are **platform-owned, force-overwritten** on template refresh (`TEMPLATE_VERSION` gate) — user customization belongs in the workspace `prompts/` override, not in the global copies.
109
+ Startup seeds `templates/prompts/{bootstrap,all,root}/` into `~/.halo/global/prompts/` (currently `BOOTSTRAP.md`; `TOOL_GUIDELINES.md`, `TOOL_SHELL[.windows].md`, `WORKSPACE_CONVENTIONS.md`, `RUNTIME.md`; `WORKSPACE_MEMORY.md`). These are **platform-owned, force-overwritten** on template refresh (`TEMPLATE_VERSION` gate) — user customization belongs in the workspace `prompts/` override, not in the global copies.
110
110
 
111
111
  ### Live load (every `buildAgentInstance`)
112
112
  `loadSystemPrompts(workspaceRoot?)` resolves each scope directory with workspace > global precedence:
@@ -141,8 +141,9 @@ Missing directory or read failure: warn + use built-in fallback.
141
141
  `composeMdPrompt(contents, roster = '', scopeBody = '')` joins non-empty sections with `\n\n---\n\n`:
142
142
 
143
143
  1. `## User Profile` (USER.md) — root agent only
144
+ - When USER.md frontmatter carries `lang`, the loader appends `Reply language: <lang> unless the user switches.` under this section.
144
145
  2. AGENT.md body
145
- 3. The live agent roster (`## Know Your Team Before You Act` for root, `## Your Team` for sub-agents), slotted directly behind AGENT.md (see [Agent roster](#agent-roster) below). Empty string for non-delegating agents (no `team` / empty `team`) / internal agents, so the section is dropped.
146
+ 3. The live agent roster (`## Your Team`), slotted directly behind AGENT.md (see [Agent roster](#agent-roster) below). Empty string for non-delegating agents (no `team` / empty `team`) / internal agents, so the section is dropped.
146
147
  4. `## User Instructions` — `~/.halo/global/INSTRUCTIONS.md` (suppressed when the workspace root has its own — see Step 2)
147
148
  5. `## User Instructions` — `<ws>/.halo/INSTRUCTIONS.md` (workspace root).
148
149
  6. `## User Instructions` — a sub-agent's `working_dir` directory-chain INSTRUCTIONS.md (`loadScopeBody`, plain markdown, headed `### <dir>`), folded into the same region right after #4/#5 so the order reads general → specific. Empty for a root agent / a working_dir with no sub-dir file. (User `@scope` does NOT come through here — it injects per-turn into the message, wrapped in `<workspace-instructions>`.)
@@ -185,7 +186,7 @@ Not injected: USER.md / `prompts/root/` / `prompts/bootstrap/`.
185
186
 
186
187
  The self-evolution agents (`__evo_agent__`, `__score__`, `__apply_agent__`) are platform tooling, not workspace-resident assistants. They get **none** of the workspace context: USER.md, INSTRUCTIONS.md (global + workspace-root), INDEX.md, and all three prompt scopes (`prompts/all` / `root` / `bootstrap`) are cleared in `composeSystemPrompt`. Only their own AGENT.md (which carries the full procedure) plus the tool list remains — this keeps their token budget clean.
187
188
 
188
- > Caveat: `prompts/all` is where `TOOL_SHELL.md` (platform shell guidance) lives, so a `shell_exec`-capable internal agent (`__apply_agent__`) doesn't inherit it. `__apply_agent__`'s AGENT.md carries its own "Shell usage & platform" section to cover this — keep that in mind if adding shell to another internal agent.
189
+ > Caveat: `prompts/all` is where `TOOL_SHELL.md` (platform shell guidance) lives, so a `shell_exec`-capable internal agent (`__apply_agent__`) doesn't inherit it. Both `__apply_agent__`'s and `goal`'s (the goal-mode judge, also an internal agent) AGENT.md carry their own "Shell usage & platform" section to cover this — keep that in mind if adding shell to another internal agent.
189
190
 
190
191
  ### Fallback
191
192
 
@@ -195,17 +196,15 @@ If `mdPrompt` is empty:
195
196
 
196
197
  ## Agent roster
197
198
 
198
- `buildAgentRoster(selfAgentId, team, isRoot)` ([session-agent-builder.ts](../../../packages/server/src/agents/session-agent-builder.ts)) builds a live team block listing the agents this session can delegate to — one `- \`<id>\` — <name>: <description>` line per teammate. The framing depends on `isRoot` (see "Root vs. sub-agent framing" below): a root gets the full `## Know Your Team Before You Act` orchestrator block, a sub-agent a lean `## Your Team` block.
199
+ `buildAgentRoster(selfAgentId, team)` ([session-agent-builder.ts](../../../packages/server/src/agents/session-agent-builder.ts)) builds a live team block listing the agents this session can delegate to — one `- \`<id>\` — <name>: <description>` line per teammate.
199
200
 
200
- **Who's on the list.** `scanAvailableAgents` minus `disabled` (workspace `disabled_items` table) minus `internal: true`, collapsed to the effective record per id (workspace shadows global — so a stale global shadow whose workspace record is disabled never gets listed-but-uncallable), then narrowed to the agent's `team` whitelist via `isTeamMember(team, id)` — the same filter `start_session` / `query_agent` enforce server-side, so the roster never lists someone the agent can't actually reach. Self is treated like any other agent: it appears only when the agent's own id is in `team`. When listed it's pinned to the top and tagged `(you)` — purely a reading order ("who am I" before "who else") — and the root framing adds a **cost warning**: spawn parallel instances of yourself only for sub-tasks that need delegation or your full generality (each instance runs your own expensive model; prefer the cheaper executor for well-scoped work), and do serial work directly rather than self-delegate. The remaining teammates are sorted by `priority` **descending** — the preferred workhorse leads and ordering is deterministic (scan order is readdir order, which isn't guaranteed). (Add the agent's own id to its `team` to enable parallel self-spawn — the seed `default` agent does exactly this.)
201
+ **Who's on the list.** `scanAvailableAgents` minus `disabled` (workspace `disabled_items` table) minus `internal: true`, collapsed to the effective record per id (workspace shadows global — so a stale global shadow whose workspace record is disabled never gets listed-but-uncallable), then narrowed to the agent's `team` whitelist via `isTeamMember(team, id)` — the same filter `start_session` / `query_agent` enforce server-side, so the roster never lists someone the agent can't actually reach. Self is treated like any other agent: it appears only when the agent's own id is in `team`. When listed it's pinned to the top and tagged `(you)` — purely a reading order ("who am I" before "who else"). The remaining teammates are sorted by `priority` **descending** — the preferred workhorse leads and ordering is deterministic (scan order is readdir order, which isn't guaranteed). (Add the agent's own id to its `team` to enable parallel self-spawn — the seed `default` agent does exactly this.)
201
202
 
202
203
  **Gated on a non-empty team.** A roster is computed only when `canDelegate(yaml)` ([agent-loader.ts](../../../packages/server/src/agents/agent-loader.ts)) holds: `!internal` (evo / score / apply are platform tooling, not orchestrators) **and** a non-empty `team`. The very same predicate gates the session-tool bundle in `resolveBaseToolSet` — so the roster and the tools that act on it are granted together or not at all, never half. No team, no delegation: no session tools, no roster.
203
204
 
204
205
  **Root and sub-agents both get a roster** — there's no `isRoot` gate on *whether* a roster appears (any agent with a non-empty `team` gets one). Runaway re-subcontracting used to be stopped by a blanket "root only" ban; it's now bounded by the per-agent `team` whitelist (which is also the delegation switch) plus `maxNestingDepth` (default 16). This lets a sub-agent legitimately delegate further (grandchild sessions) when its `agent.yaml` declares a team, while the depth cap and whitelist keep cascades finite.
205
206
 
206
- **Root vs. sub-agent framing.** `isRoot` controls the *framing* around the roster, not its membership:
207
- - **Root** gets `## Know Your Team Before You Act` — the roster plus the full orchestrator pep-talk (prefer delegation, fan out in parallel, don't poll, "I'll just do it myself" is rarely right). A root's job is to orchestrate, so the steering is on-message.
208
- - **Sub-agent** gets `## Your Team` — the same roster plus a single line on when to hand off. A sub-agent's job is to *finish what it was handed*, not to keep re-subcontracting, so the orchestrator pep-talk would be noise (or actively push it to over-delegate). Self's line is also trimmed to just `(you)` (no "spawn parallel instances of yourself" nudge).
207
+ **Facts, not policy.** The block states who is reachable and how (`start_session` / `query_agent`) and explicitly defers *when and how much to delegate* to the workspace INSTRUCTIONS. Root and sub-agents get the identical block — an earlier version gave roots an orchestrator pep-talk ("prefer delegation", "fan out", "don't poll", a self-spawn cost warning) that could contradict whatever stance a workspace's INSTRUCTIONS.md took; delegation policy now lives in exactly one place.
209
208
 
210
209
  **Team whitelist = the delegation switch.** `agent.yaml` carries an optional `team: [id, …]`. A **non-empty** list is what *enables* delegation — it grants the whole session-tool bundle plus the roster (see `canDelegate`) AND restricts reach to exactly those ids. **Unset or empty `[]` means the agent cannot delegate at all** (no session tools, no roster) — this is a breaking change from the earlier "unset = every agent reachable" default; agents authored before this change that relied on the implicit-all behavior must now list their team explicitly. The `isTeamMember(team, targetId)` predicate still gates the three reach surfaces consistently — the roster (what the agent sees), `start_session`, and `query_agent` (server-side enforcement, so a hand-crafted call to a non-team agent is rejected). Self gets no special-casing: include the agent's own id to allow parallel self-spawn (the seed `default` agent lists `default`), omit it to block self-spawn — exactly like any other agent.
211
210
 
@@ -249,7 +248,7 @@ It exists because a plain-text answer is single-pass: the agent loop only re-cal
249
248
  [bootstrapPrompt] ← prefixed when needsBootstrap (ws > global)
250
249
  USER.md ← workspace > global
251
250
  AGENT.md ← workspace > global
252
- ## Know Your Team Before You Act ← root's roster framing (dropped when team is empty/unset)
251
+ ## Your Team ← roster (dropped when team is empty/unset)
253
252
  ## User Instructions ← ~/.halo/global/INSTRUCTIONS.md (suppressed when ws has its own)
254
253
  ## User Instructions ← <ws>/.halo/INSTRUCTIONS.md (workspace root)
255
254
  ## Project Knowledge ← <ws>/.halo/INDEX.md (or nudge)
@@ -269,7 +268,7 @@ root-scope leads all-scope: root-only orchestrator guidance lands while attentio
269
268
 
270
269
  ```
271
270
  AGENT.md
272
- ## Your Team ← lean roster (only when the agent has a non-empty team)
271
+ ## Your Team ← roster (only when the agent has a non-empty team)
273
272
  ## User Instructions ← ~/.halo/global/INSTRUCTIONS.md (suppressed when ws has its own)
274
273
  ## User Instructions ← <ws>/.halo/INSTRUCTIONS.md (workspace root)
275
274
  ## User Instructions ← working_dir's directory-chain INSTRUCTIONS.md, headed ### <dir> (when working_dir set)
@@ -281,7 +280,7 @@ allPrompt ← prompts/all/*.md (ws > glob
281
280
  Your available tools: ...
282
281
  ```
283
282
 
284
- (Sub-agents get the lean `## Your Team` roster — not the root's `## Know Your Team Before You Act` block — and only when the agent has a non-empty team; no USER.md, no root-scope prompts.)
283
+ (Sub-agents get the same `## Your Team` roster as roots, only when the agent has a non-empty team; no USER.md, no root-scope prompts.)
285
284
 
286
285
  ## Directory-scoped instructions
287
286
 
@@ -128,6 +128,7 @@ Fix (the rule: **agentId is the only identity; nothing that locates or persists
128
128
  Hierarchical encoding: `root_id>child_segment>grandchild_segment`.
129
129
  - Depth = `id.split('>').length`
130
130
  - Root ID = `id.split('>')[0]` (O(1), no DB walk)
131
+ - All descendants of `X` = the id range `X>` … `X>U+FFFF` — one query, any depth, archived included (`SessionQueryStore.listDescendantIds`; `listDescendants` uses the same range). `stopSession` / `deleteSession` / `archiveSessionTree` and the DELETE route's no-manager fallback all build their cascade set from it — previously four copies of a per-level `WHERE parent_id = ?` recursion, one select per node. The encoding is an invariant: `createSession` mints children as `${parentId}>${segment}`, and every `explicitId` caller passes `parentId: null`.
131
132
 
132
133
  ### Lifecycle
133
134
 
@@ -265,7 +266,7 @@ Status is derived from memory (`promise !== null`) — not stored.
265
266
  - **One write seam.** `saveSessionToFile()` returns the header it just wrote (`SessionFileMeta`); `SessionManager.persistSessionFile` — the single funnel every UI-log persist goes through — hands it to `mirrorSessionMeta()`. Nothing else writes these columns except the PATCH rename path, which sets `title` alongside the file rewrite.
266
267
  - **Idempotent, and never touches `updated_at`.** `mirrorSessionMeta` selects first and skips the UPDATE when all four values already match; a missing row is a no-op return (sub-sessions of a deleted tree). It deliberately leaves `updated_at` alone — that column drives list ordering and "last activity", and a metadata mirror is not activity.
267
268
  - **`exchange_count` is a lifetime count**, `countMainUserMessages(messages) + archivedUserCount`, so it doesn't shrink when a compact archives history (unlike the file's `messageCount`).
268
- - **Lazy backfill.** The list route treats `exchange_count === null` as "pre-migration row", reads the file once via `readSessionFileMeta`, mirrors it, and never pays that cost again.
269
+ - **Lazy backfill.** The list route treats `exchange_count === null` as "pre-migration row", reads the file once via `readSessionFileMeta`, mirrors it, and never pays that cost again. `getSessionTitle` (channel `/list`, `/tree`) applies the same rule: it returns the `title` column and only opens the file for an un-mirrored row — it used to sync-read and `JSON.parse` the whole session file per row, which at 50 MB-scale sessions stalled the event loop for every other session's stream.
269
270
 
270
271
  ## Conversation repair
271
272
 
@@ -331,9 +332,9 @@ Frontend network issues don't affect the backend:
331
332
 
332
333
  | Error | Recovery |
333
334
  |---|---|
334
- | User abort / graceful interrupt (`AbortError`) | Repair, clean exit |
335
+ | User abort / graceful interrupt — **our own `signal.aborted`** or `err.name === 'AbortError'` | Repair, clean exit |
335
336
  | Context overflow (`too many input tokens`) | **Local** (non-LLM) compact + retry — the model already refused this payload, so calling an LLM risks a second stall |
336
- | Account-level error (insufficient balance / suspended / invalid key / unauthorized) | Unrecoverable — report to user, **no** retry |
337
+ | Account-level error — **`httpStatus` 401 / 402 / 403** when a status is available; keyword match (insufficient balance / suspended / invalid key / unauthorized / authentication) only when none is | Unrecoverable — report to user, **no** retry |
337
338
  | Rate limiting / throttling | Exponential backoff (`2s * 2^attempt` + jitter, capped at 60s: 2s/4s/8s/16s…), retry |
338
339
  | **Transient server-side error (5xx / timeout)** | Same exponential backoff as throttling — see [Transient server-error classification](#transient-server-error-classification) below |
339
340
  | Transient transport error (`fetch failed`, `ECONNRESET`, headers timeout, …) | Short backoff (`1s * 2^attempt` + jitter), retry |
@@ -341,6 +342,8 @@ Frontend network issues don't affect the backend:
341
342
  | **4xx multimodal rejection** (`Multimodal data is corrupted` / `Could not process image`) | Replace all image blocks in history with text placeholders, persist, retry — see [Multimodal 4xx degrade](#multimodal-4xx-degrade) below |
342
343
  | Unrecoverable error | Report to user, stop |
343
344
 
345
+ **Classification order: structured signal first, message text last.** The interrupt branch is decided by the attempt's own `AbortController` signal (every interrupt path we own goes through `abortReason()` on it), never by the words `cancelled` / `aborted` in the message — an upstream error body can legitimately read `"The operation was aborted due to …"` (Bedrock), and matching that swallowed a real failure as a user interrupt: silent `break`, no retry, no error event, an empty reply. Likewise the account-level branch trusts `httpStatus` when one was recovered: 401/402/403 is terminal, any *other* status is not, whatever the body says — an OpenAI-compatible 503 whose body reads `"authentication service temporarily unavailable"` used to be classified as a dead key and never retried. The keyword list is only consulted when no status could be extracted. Pinned by `turn-retry-idempotent.test.ts` (five classification cases).
346
+
344
347
  **Abort reasons are normalized through the `abortReason()` helper** — all three abort call sites (graceful interrupt after `tool_result`, `interruptSession`'s hard interrupt, `stopSession`'s stop) wrap the reason string in `new DOMException(reason, 'AbortError')` instead of passing it raw. Node 22 gotcha: `fetch` rejects with the abort reason **as-is**, so `controller.abort('interrupt')` surfaced the bare string `'interrupt'` (not an Error, `errName === ''`) in `runAgentTurn`'s catch — it missed the AbortError branch and logged a fake "Unrecoverable: interrupt" error. Contract pinned by a queue-semantics test.
345
348
 
346
349
  ### Multimodal 4xx degrade
@@ -388,6 +391,10 @@ All paths share the same split logic — the private `compactCut(messages)` on S
388
391
 
389
392
  **Notification contract — a "Compacting context…" preflight is only announced when compaction will actually run, and always gets an outcome line.** Both self-compact entry points (`maybeAutoCompact`, manual `compactSession`) check `compactCut() === 0` *before* emitting the preflight and bail silently when there's nothing to compact. After the preflight, every path closes it out: success pairs it with `Auto-compacted N older messages` / `Context compacted: …`; an empty LLM summary (a thinking-heavy model can legally spend its entire response on non-text blocks) emits `Compaction skipped — no summary produced`; a thrown summarize call emits `Compaction failed — context unchanged` (auto path swallows the error, manual path rethrows after emitting). Previously the preflight fired first and `selfCompactSession`'s silent `return null` paths left an orphan "Compacting context…" with no outcome — see `memory/2026-08-04-compact-preflight-orphan.md` for the production forensics.
390
393
 
394
+ **Post-compact token estimate.** Every compact ends with `estimateMessageTokens(messages) + estimateMessageTokens(systemPrompt)` written to `session.lastContextTokens` — the number the 80% gate compares against until the next real `usage` event arrives. It is a heuristic (`chars / 3.5` for mixed CJK/English), and it counts every block type: `text` by length, `tool_use` by `name + JSON(input)`, `tool_result` by its string or nested text blocks, and each image — top-level or nested in a `tool_result` — a flat **1500 tokens** (Anthropic's `w*h/750` rule caps near 1600 at max size; we don't decode dimensions). A text-only count under-read image- and tool-heavy histories and pushed the next auto-compact out until the model itself rejected the payload.
395
+
396
+ **Local compact output is API-legal on its own.** `localCompactMessages` returns `[summary, ...recent]` where `recent` starts past any `tool_result`-first user message, so every kept `tool_result` has its `tool_use` in the kept tail and `repairConversationMessages` is a no-op on the result (also on a second pass over its own output). Pinned by `local-compact-roundtrip.test.ts` — without it the overflow retry would only have "worked" via the later corrupted-conversation repair retry.
397
+
391
398
  Config (see `config.compact`): `keepMessages` / `maxSummaryInput` / `maxMessageSlice` / `summarizeTimeoutSec` — editable in Settings → General → compact.
392
399
 
393
400
  **Self-compact also archives the UI log.** `selfCompactSession` calls `uiStore.archiveOldMessages(session.id)` for **root sessions only** (`!session.parentId`), right before the compaction notice is emitted so the notice lands in the kept exchange rather than inside the archived segment. Compaction is the one path that already means "history shrinks here", which is why archiving hangs off it rather than off a timer or its own sweep — the size threshold below decides whether the call does anything. See [UI-log archiving](#ui-log-archiving).
@@ -98,7 +98,7 @@ data: {"type":"error","error":"..."}
98
98
  By default each token is bound 1:1 to the workspace its admin row configured, and `/web/chat` operates on the account's "active" session (most-recently-used or one set by `/session new` / `/session switch`). External integrations — currently the [ACP adapter](../dev/acp-adapter.md) — need finer control:
99
99
 
100
100
  - `workspace` (string, optional): server-side absolute path. Overrides `account.workspacePath` for this request only. **Gated on `accessLevel === 'full'`** — readonly / workspace tokens cannot escape their account-bound workspace; the gate returns an SSE `error` event.
101
- - `sessionId` (string, optional): explicit halo session id. Bypasses the account's active-session pointer entirely. If the session doesn't yet exist, the server creates it with the supplied id (so callers can pre-mint stable ids and address them across reconnects).
101
+ - `sessionId` (string, optional): explicit halo session id. Bypasses the account's active-session pointer entirely. **Gated by ownership**: `full` tokens may address any id; readonly / workspace tokens only ids their own account minted (`web_<accountId>_*`) — anything else is refused with HTTP 403 `{error: "session not owned by this token"}` at the route layer (before the SSE stream opens), on `/web/chat`, `/web/stop`, `/web/history` and `/web/subscribe` alike. If the session doesn't yet exist, the server creates it with the supplied id (so callers can pre-mint stable ids and address them across reconnects).
102
102
  - `agentId` (string, optional): only consulted when the request is creating a new session (no row yet for `sessionId`). Picks the agent profile to bootstrap with. Defaults to `default`.
103
103
 
104
104
  Browser web-demo doesn't use any of these — it relies on per-token defaults. Three accepted transports per request, lowest-priority first:
@@ -107,7 +107,17 @@ Browser web-demo doesn't use any of these — it relies on per-token defaults. T
107
107
  2. Headers: `x-workspace`, `x-session-id`, `x-agent-id`
108
108
  3. POST body fields (highest priority).
109
109
 
110
- `/api/web/stop`, `/api/web/history`, `/api/web/subscribe` accept the same `workspace` + `sessionId` overrides via query string / header. They don't accept `agentId` (no session creation path).
110
+ `/api/web/stop`, `/api/web/history`, `/api/web/subscribe` accept the same `workspace` + `sessionId` overrides via query string / header, and the same ownership gate. They don't accept `agentId` (no session creation path).
111
+
112
+ ### POST `/api/web/sessions`
113
+
114
+ Mints a root session in the token's own `web_<accountId>_<ts>_<rand>` namespace, creates the row immediately, and returns `{ sessionId }`. Accepts `workspace` (full tokens only — same gate as above, refused with 403) and `agentId` (agent profile to bootstrap with, defaults to the workspace's default agent) via body / query / header; there is no `sessionId` override by definition.
115
+
116
+ Why it exists: the ownership gate above means a readonly / workspace token can only address ids under its own prefix, so an external caller cannot pre-mint an id of its own choosing and expect to drive it. The [ACP adapter](../dev/acp-adapter.md) used to mint `web_acp_*` locally and 403'd on the first prompt for anything but a full token; its `session/new` now calls this endpoint instead, so ACP id === halo id still holds and every later `/web/chat|stop|history|subscribe` with that id passes the gate.
117
+
118
+ Minting never touches the account's active-session pointer — a side session created by an integration must not clobber the browser tab's notion of "current session".
119
+
120
+ **Namespace sharing**: sessions minted here live under the same `web_<accountId>_` prefix as browser sessions. When the token has no active-session pointer, `/web/chat` without `sessionId` falls back to the latest root session under that prefix — which may be an API-minted one. Use a dedicated token for ACP if the same token also drives a browser client.
111
121
 
112
122
  ### POST `/api/web/stop`
113
123
 
@@ -112,7 +112,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
112
112
  | `__pong__` | `__ping__` handler | Reply to the client's application-level liveness probe |
113
113
  | `listener:released` | `reclaimIfAbandoned` (this client only) | This connection's event listener was reclaimed (silent >3 min / CLOSED) — `{sessionId}`. Client must re-`subscribe` to reattach. See [Abandoned-listener reclaim](#abandoned-listener-reclaim-and-the-__ping__-contract). |
114
114
  | `chat:queued` | `sendUserMessage` returning queued | User-message-queued notification |
115
- | `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`), 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). |
115
+ | `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). |
116
116
  | `terminal:ready` / `terminal:output` / `terminal:exit` / `terminal:reattached` | TerminalManager | PTY output |
117
117
  | `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). |
118
118
  | `session:switched` | handler.ts (this client only) | The server rebound this connection to a different session — see [switchTo rebind](#switchto-rebind--sessionswitched) |
@@ -149,13 +149,13 @@ interface ConnectedClient {
149
149
  backgroundSaves: Map<string, () => void>
150
150
  unsubscribeEvents: (() => void) | null
151
151
  terminalManager: TerminalManager
152
- fileWatcher: WorkspaceWatcher
153
- gitDirWatcher: GitDirWatcher
154
152
  lastClientPingAt: number // wall-clock ms of last INBOUND frame — the reclaim's liveness stamp
155
153
  commandUserId: string // `ws-<n>`, this connection's key into the command layer's active-session map
156
154
  }
157
155
  ```
158
156
 
157
+ File watchers are **not** per connection: `ws/watcher-pool.ts` keeps one `WorkspaceWatcher` + `GitDirWatcher` per workspace root and fans each event out to every socket attached to that root (`attach` on subscribe / chat-bind, `detach` on close; the watchers stop when the last socket leaves). N tabs on one workspace = one native recursive subscription, not N.
158
+
159
159
  UI state (messageLog / streamBuffer / turnToolCalls / tokens) belongs to SessionManager's `UIState`, not the client.
160
160
 
161
161
  ### Command dispatch
@@ -24,7 +24,7 @@ Claude Code on a developer's laptop wants to talk to a halo agent running in an
24
24
 
25
25
  ## Quick start
26
26
 
27
- 1. Provision a web-channel token. Admin UI → Channels → Web → Create. Grab the token. **For multi-workspace use, pick `full` access level** — readonly / workspace tokens cannot override the workspace per request.
27
+ 1. Provision a web-channel token. Admin UI → Channels → Web → Create. Grab the token. **For multi-workspace use, pick `full` access level** — readonly / workspace tokens cannot override the workspace per request. For single-workspace use a readonly / workspace token works too: sessions are minted server-side in the token's own namespace, so the ownership gate never bites.
28
28
 
29
29
  2. Launch the adapter from your ACP client. For Claude Code, register it as a custom agent (see Claude Code's docs for `claude-code config agent add`):
30
30
 
@@ -80,7 +80,7 @@ To remove a binding: `/acp remove` (deletes the skill directory and points out t
80
80
  | `--scheme` | no | URL scheme — `http` or `https` (default `http`). Use `https` when the server sits behind a TLS reverse proxy. Invalid values are rejected at the adapter boundary. |
81
81
  | `--token` | yes | Web-channel token from admin UI. `full` access required for multi-workspace use. |
82
82
  | `--workspace` | yes | Absolute server-side path for the workspace this adapter drives. |
83
- | `--agent-id` | no | Halo agent profile to use when ACP `session/new` creates a new halo session. Default: `default`. |
83
+ | `--agent-id` | no | Halo agent profile to use when ACP `session/new` creates a new halo session. Default: `default`. Alias: `--agent` (matches the main CLI's `--agent`). |
84
84
  | `--header` | no | Extra HTTP header on every upstream request, `"Name: value"` (like `curl -H`). Repeatable. For auth that sits **in front of** the halo server — see "Upstream auth" below. |
85
85
 
86
86
  One adapter process binds to one workspace. To drive multiple workspaces concurrently from the same token, run multiple adapter processes — see "Multi-workspace" below.
@@ -116,7 +116,7 @@ Notes:
116
116
  |---------------------|--------------|------------------------------------------------------------------------|
117
117
  | `initialize` | ✅ | Declares `protocolVersion: 1`, `promptCapabilities: { image, embeddedContext }`, `loadSession: true`, no auth methods. |
118
118
  | `authenticate` | ✅ (no-op) | Token already passed via launch flags; ACP-side auth has nothing to do. |
119
- | `session/new` | ✅ | Mints a session id (shape `web_acp_<ts>_<rand>`) and registers it locally. Halo creates the row lazily on the first `/web/chat` with that id. |
119
+ | `session/new` | ✅ | Calls `POST /api/web/sessions`; the server mints `web_<accountId>_<ts>_<rand>` and creates the row immediately. The adapter registers the id locally. |
120
120
  | `session/load` | ✅ | Verifies the supplied id still exists on the halo server (via `/api/web/history` 404), then registers it locally. The ACP client persists ids itself — the adapter holds no on-disk state. |
121
121
  | `session/prompt` | ✅ | Forwards text + image content blocks to halo. Resource / embedded-context blocks log a stderr warning and are dropped (see "Reverse fs" below for why). |
122
122
  | `session/cancel` | ✅ | Aborts the in-flight HTTP/SSE stream and POSTs `/web/stop` server-side. |
@@ -126,7 +126,7 @@ Notes:
126
126
 
127
127
  ### Session id model
128
128
 
129
- ACP sessionId == halo sessionId. There's no extra mapping layer in the adapter: when `session/new` mints `web_acp_<ts>_<rand>`, that exact string IS the row in `agent_sessions` (created lazily on first `/web/chat`). When the ACP client persists the id and replays it via `session/load`, the adapter just calls `/api/web/history?sessionId=<id>` to verify the row still exists, then registers it in its local in-memory map for prompt / cancel routing.
129
+ ACP sessionId == halo sessionId. There's no extra mapping layer in the adapter: `session/new` asks the server for a fresh session (`POST /api/web/sessions`), which mints `web_<accountId>_<ts>_<rand>` inside the token's own namespace and creates the `agent_sessions` row on the spot — that exact string is what the ACP client gets back. The server has to be the one minting because readonly / workspace tokens can only address ids under their own `web_<accountId>_` prefix (`canAddressSession` in `packages/server/src/channels/web/handler.ts`); an adapter-chosen id would 403 on the first prompt. When the ACP client persists the id and replays it via `session/load`, the adapter just calls `/api/web/history?sessionId=<id>` to verify the row still exists, then registers it in its local in-memory map for prompt / cancel routing.
130
130
 
131
131
  This keeps the adapter stateless on disk — losing the in-memory map on restart is harmless because the conversation lives on the halo server. **The ACP client is the source of truth for "which sessions are mine"**, which is the right shape: a Mac-side Claude Code knows about *its* sessions, the EC2-side halo agent doesn't need to enumerate them.
132
132
 
@@ -254,7 +254,7 @@ python3 .halo/skills/ask-sa-agent/ask.py \
254
254
  Expect stdout:
255
255
 
256
256
  ```
257
- SESSION: web_acp_<ts>_<rand>
257
+ SESSION: web_<accountId>_<ts>_<rand>
258
258
  ---
259
259
  本月(2026-05…)EC2 总花费 约 $1,846 …
260
260
  ```
@@ -2,7 +2,7 @@
2
2
 
3
3
  All REST endpoints are served by Hono on port 9527 at `/api/`.
4
4
 
5
- Auth: most `/api/*` routes require a valid JWT cookie (`halo_token`). Exceptions in `PUBLIC_PATHS` (`middleware/auth.ts`) bypass the cookie: `/api/auth/login|check|logout` (but **not** `/api/auth/change-password`), the web-channel routes (`/api/web/chat|stop|history|subscribe|file`), `/api/show/state|session`, and `/api/metrics` — these are unauthenticated or use a web-channel `x-token` instead.
5
+ Auth: most `/api/*` routes require a valid JWT cookie (`halo_token`). Exceptions in `PUBLIC_PATHS` (`middleware/auth.ts`) bypass the cookie: `/api/auth/login|check|logout` (but **not** `/api/auth/change-password`), the web-channel routes (`/api/web/chat|sessions|stop|history|subscribe|file`), `/api/show/state|session`, and `/api/metrics` — these are unauthenticated or use a web-channel `x-token` instead.
6
6
 
7
7
  Compression: every response negotiates gzip/deflate via `hono/compress` (`threshold: 1024` — bodies under 1 KiB pass through uncompressed), mounted after CORS so it covers both `/api/*` JSON and the served admin static assets. `text/event-stream` (SSE) is excluded, so `/api/web/chat`'s streamed reply is unaffected; WS upgrades never enter the Hono fetch pipeline either.
8
8
 
@@ -268,11 +268,12 @@ gets JSON, and additionally gates on `accessLevel`.
268
268
 
269
269
  | Method | Path | Purpose |
270
270
  |---|---|---|
271
- | POST | `/api/web/chat` | Send message, receive SSE stream. Body: `{message, images?, workspace?, sessionId?, agentId?}` (overrides also accepted as `?workspace=`/`?sessionId=` query or `x-workspace`/`x-session-id` headers; `workspace` only honored when token has `accessLevel: full`) |
271
+ | POST | `/api/web/chat` | Send message, receive SSE stream. Body: `{message, images?, workspace?, sessionId?, agentId?}` (overrides also accepted as `?workspace=`/`?sessionId=` query or `x-workspace`/`x-session-id` headers; `workspace` only honored when token has `accessLevel: full`). `sessionId` must be owned by the token unless `accessLevel: full` → otherwise 403. |
272
+ | POST | `/api/web/sessions` | Mint a root session in the token's own `web_<accountId>_` namespace → `{sessionId}`. Body / query / header: `workspace?` (full tokens only), `agentId?`. Used by the ACP adapter's `session/new`. |
272
273
  | POST | `/api/web/stop` | Stop running task → `{stopped: boolean}` |
273
274
  | GET | `/api/web/history` | Active session history → `{sessionId, messages[], running}` |
274
275
  | GET | `/api/web/subscribe` | Reconnect SSE to running session |
275
- | GET | `/api/web/file?path=` | Inline-serve a workspace-relative file (image / video / pdf etc.). Path-traversal-checked against the token's bound workspace: the lexical check is re-verified against the realpath'd root, so a symlink inside the workspace pointing outside it returns 403; a dangling symlink (target doesn't exist) returns 404. |
276
+ | GET | `/api/web/file?path=` | Inline-serve a workspace-relative file (image / video / pdf etc.). Path-traversal-checked against the token's bound workspace: the lexical check is re-verified against the realpath'd root, so a symlink inside the workspace pointing outside it returns 403; a dangling symlink (target doesn't exist) returns 404. Workspace runtime state (`.halo/sessions`, `.halo/logs`, `.halo/evo`, `halo.db*`) is hidden for every access level → 403 `{error: "path is not accessible"}`, checked on the realpath so symlinks into those dirs are caught too. |
276
277
 
277
278
  See [design/web.md](../design/web.md).
278
279
 
@@ -287,7 +288,7 @@ runtime so the frontend can render rooms (workspaces) + characters (sessions).
287
288
  | Method | Path | Purpose |
288
289
  |---|---|---|
289
290
  | GET | `/api/show/state` | `full` or `observer` token → every known workspace; otherwise the account's own. Returns `{ serverTime, uptime, accessLevel, skills[], workspaces[] }` |
290
- | GET | `/api/show/session?ws=&id=` | Inspector-panel detail for a single session. Trimmed message log (last 40, content/tool I/O capped) plus `contextTokens` / `outputTokens` / `maxContextTokens` / `isRunning`. Non-`full`/`observer` tokens may only read their own workspace. |
291
+ | GET | `/api/show/session?ws=&id=` | Inspector-panel detail for a single session. Trimmed message log (last 40, content/tool I/O capped) plus `contextTokens` / `outputTokens` / `maxContextTokens` / `isRunning`. Non-`full`/`observer` tokens may only read their own workspace, and within it only sessions their own account minted (`web_<accountId>_*`); anything else is 403. |
291
292
 
292
293
  `observer` is more than an aggregate-counts role: past `/show/state`, it can also call `/show/session` to read **any workspace's** session transcript (last 40 messages, content truncated to 600 chars, tool input to 200 chars). Mint it knowing it grants cross-workspace transcript read access, not just dashboard counters.
293
294
 
@@ -200,7 +200,7 @@ This installs the `halo` binary on `$PATH`. Subcommands available:
200
200
 
201
201
  1. **Bump version** in the five workspace `package.json` files (`packages/{cli,server,core,admin,desktop}/package.json`) — the root `package.json` has no version field.
202
202
  2. **Update `CHANGELOG.md`**: rename `[Unreleased]` → `[x.y.z] - YYYY-MM-DD`, add a fresh empty `[Unreleased]` section above it, and roll the link references at the bottom (add `[x.y.z]: compare/v<prev>...vx.y.z`, repoint `[Unreleased]` to `compare/vx.y.z...HEAD`).
203
- 3. **Bump `TEMPLATE_VERSION`** in `packages/server/src/init.ts` if any file under `templates/` was touched.
203
+ 3. **Bump `TEMPLATE_VERSION`** in `packages/server/src/init.ts` if any file under `templates/` was touched. `build-bundle.mjs` enforces this: it diffs `packages/server/templates` against the previous release tag and exits 1 when files changed but the number didn't move (so `pnpm bundle` and every desktop `dist:*` refuse to package a silent template edit).
204
204
  4. **Build admin**: `pnpm --filter @turmind/halo-admin build` — verify `admin/out/monaco/vs/loader.js` exists.
205
205
  5. Commit, tag `vx.y.z`, push, then publish.
206
206
 
@@ -356,9 +356,11 @@ Still good practice: build server + admin **before** `pnpm dist:arm64`.
356
356
  `cli` + `desktop` were already bumped by hand but `server` sat at 0.1.8 and
357
357
  `core` / `admin` at 0.1.0 — a `git tag` then would have shipped a build whose
358
358
  `halo --version` (stamped from `package.json`) disagreed with the tag. The
359
- step-0 version gate in "One-shot build" loops over all five so a lagging one is
360
- caught before the tag is cut. (npm only publishes `cli` as `@turmind/halo`, but
361
- the others feed `halo --version` / the admin sidebar, so they still matter.)
359
+ step-0 version gate in "One-shot build" (and `build-bundle.mjs` enforces it
360
+ under `HALO_RELEASE=1`, exiting 1 when the five differ) loops over all five
361
+ so a lagging one is caught before the tag is cut. (npm only publishes `cli`
362
+ as `@turmind/halo`, but the others feed `halo --version` / the admin
363
+ sidebar, so they still matter.)
362
364
 
363
365
  - **Changing anything under `templates/` requires bumping `TEMPLATE_VERSION`
364
366
  (`packages/server/src/init.ts`) — otherwise the change never reaches existing
@@ -373,7 +375,9 @@ Still good practice: build server + admin **before** `pnpm dist:arm64`.
373
375
  integer, not a hash — it must increase monotonically (a git sha would compare
374
376
  nonsensically and can decrease). Fresh `halo setup` always reseeds regardless,
375
377
  which is why it's easy to miss in dev. Rule: touch `templates/` → `+1` the
376
- version in the same commit.
378
+ version in the same commit. Since v1.1.9 `build-bundle.mjs` (run by every
379
+ `pnpm bundle` / desktop `dist:*`) enforces it: templates differ from the
380
+ previous release tag + number unchanged → exit 1 with the file list.
377
381
 
378
382
  - **Crash diagnostics.** Server stdout/stderr is tee'd to
379
383
  `~/.halo/logs/desktop.log`; the last 30 lines also surface in the crash
@@ -101,7 +101,7 @@ Source: `packages/server/src/config.ts`
101
101
  | `HALO_CORS_ORIGINS` | empty (reflect any origin) | `config.yaml server.cors_origins` | CORS allowlist (comma-separated). Empty = reflect any incoming Origin so credentials work cross-origin. Set explicit list to enforce strict CORS. |
102
102
  | `HALO_FRONTEND_DIR` | `packages/admin/out` | — | Static frontend dir (resolved as absolute path from project root) |
103
103
  | `HALO_MAX_CONTEXT_TOKENS` | `200000` | — | Model max context |
104
- | `HALO_SHELL_TIMEOUT` | `600000` | `config.yaml timeout.shell_exec` | Shell command timeout (ms) |
104
+ | `HALO_SHELL_TIMEOUT` | `120000` | `config.yaml timeout.shell_exec` | Shell command timeout (ms) |
105
105
  | `HALO_WEB_FETCH_TIMEOUT` | `10000` | `config.yaml timeout.web_fetch` | web_fetch timeout |
106
106
  | `HALO_MODEL_TIMEOUT` | `1800000` | `config.yaml timeout.model_request` | Per model-call wall-clock cap (ms, 30 min). A bare fetch has no default timeout, so a half-open connection would hang the request forever; on expiry the call aborts and the agent loop retries with backoff. |
107
107
  | `HALO_SESSION_GRACE` | `300000` | `config.yaml timeout.session_grace` | WS-disconnect session grace |
@@ -68,7 +68,7 @@ Run a shell command. Full shell access.
68
68
  |---|---|---|---|
69
69
  | command | string | yes | Shell command |
70
70
 
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.
71
+ Timeout 120 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
 
@@ -83,7 +83,7 @@ Regex content search. Returns `file:line:content`.
83
83
  | include | string | no | Glob-like filename filter, e.g. `*.ts`, `*.{ts,tsx}`, or a comma-separated list `*.ts,*.tsx` |
84
84
  | max_results | number | no | Max matching lines (default 50) |
85
85
 
86
- Skips: `node_modules` / `.git` / `.next` / `dist` / `.halo` / binary files.
86
+ Skips: `node_modules` / `.git` / `.next` / `dist` / binary files. `.halo/` itself IS walked (it holds the agent's knowledge base — memory/, docs/, INSTRUCTIONS.md, skills/); only its machine-generated subtrees `sessions/ logs/ evo/ tmp/ assets/ canvas/` are skipped, and only when the direct parent is `.halo` — a project's own `logs/` or `tmp/` is still searched.
87
87
 
88
88
  ### glob
89
89
 
@@ -129,6 +129,16 @@ Enforcement layers:
129
129
 
130
130
  Dependency: `bubblewrap` (`apt install bubblewrap`) on Linux. Without it, only layers 2 + 3 are active.
131
131
 
132
+ **Windows has no sandbox.** There is no bwrap equivalent, so `sandbox.ts` (`normalizeOptsForPlatform`) promotes every non-full call to `full` before it reaches layer 1 or layer 3 — `assertPathAllowed()` returns immediately without checking the workspace boundary or the hidden-path lists. Only layer 2 (tool filtering) survives, because `isBwrapCached()` is always false there. Net effect on Windows:
133
+
134
+ | Level | Effective behavior on Windows |
135
+ |---|---|
136
+ | `full` | Unchanged |
137
+ | `workspace` | **Same as `full`** — all 9 tools, `shell_exec` runs unsandboxed, file tools can read/write any path the halo process can |
138
+ | `readonly` | 5 read-only tools (no write / shell / fetch), but **no path boundary** — `file_read` / `grep` / `glob` can reach anything on disk, including `~/.halo/secrets/` and `.halo/sessions/` |
139
+
140
+ This is a known, unfixed gap (not a bug in a specific route): don't hand out `workspace` / `readonly` channel tokens on a Windows-hosted server expecting isolation — treat them as `full` and `read-anything` respectively. Fixing it properly needs a Windows path-boundary check in `assertPathAllowed` and a Windows equivalent of the shell sandbox, neither of which exists yet.
141
+
132
142
  ### Sandbox hidden paths
133
143
 
134
144
  Sensitive directories and files are hidden from workspace/readonly sessions via bwrap overlays (and the same lists gate `assertPathAllowed` on the no-bwrap fallback). Paths that don't exist on the filesystem are silently skipped. Two categories coexist:
@@ -163,7 +173,7 @@ Per-channel defaults:
163
173
  `grep` and `glob` read the first 512 bytes looking for a null byte and skip binaries.
164
174
 
165
175
  ### Tool result budget
166
- The orchestrator truncates tool results over 8000 chars and appends a `[Content truncated]` hint telling the agent to use `grep` for a targeted search.
176
+ The orchestrator truncates tool results over 8000 chars and appends a `[Content truncated]` hint telling the agent to use `grep` for a targeted search. `activate_skill` results are exempt — a SKILL.md body is instructions, not data, and the built-in acp / cron / self skills exceed 8K.
167
177
 
168
178
  ## Session tools
169
179
 
@@ -280,7 +290,7 @@ In-memory sessions return `session.output`; released sessions read the `output`
280
290
 
281
291
  ### query_agent
282
292
 
283
- Get an agent's full details: AGENT.md, model config, tools, skills. Use it before `start_session` to decide if an agent fits. Team-gated: an agent can only inspect agents on its own roster (the `team` whitelist — see [agent roster](../design/prompt-system.md#agent-roster)); querying a non-team agent is rejected.
293
+ Show an agent's name, description, model, tool list and skill descriptions — enough to decide whether it fits before `start_session`. Does not include AGENT.md (read `.halo/agents/<id>/AGENT.md` for behavior rules). Team-gated: an agent can only inspect agents on its own roster (the `team` whitelist — see [agent roster](../design/prompt-system.md#agent-roster)); querying a non-team agent is rejected.
284
294
 
285
295
  | Arg | Type | Required | Description |
286
296
  |---|---|---|---|
@@ -389,11 +399,11 @@ There is **no implicit default tool set**: `filterTools()` (in `agent-loader.ts`
389
399
 
390
400
  | Config key | Default | Purpose |
391
401
  |---|---|---|
392
- | `timeout.shellExec` | 600,000 ms | Shell command timeout |
402
+ | `timeout.shellExec` | 120,000 ms | Shell command timeout |
393
403
  | `timeout.webFetch` | 10,000 ms | HTTP timeout |
394
404
  | `limits.shellOutputBuffer` | 5 MB | Shell output buffer |
395
405
  | `limits.webFetchMaxBody` | 50 KB | web_fetch body cap |
396
406
  | `limits.grepDefaultMax` | 50 | Default grep result cap |
397
- | `limits.toolResultMax` | 8,000 chars | Tool result truncation threshold |
407
+ | `limits.toolResultMax` | 8,000 chars | Tool result truncation threshold (`activate_skill` exempt) |
398
408
 
399
409
  Defined in `packages/server/src/config.ts`.
@@ -53,7 +53,7 @@ halo acp \
53
53
  | `--port` | yes | Halo server port |
54
54
  | `--token` | yes | Web-channel token. `full` access required for multi-workspace use |
55
55
  | `--workspace` | yes | Absolute server-side path for this adapter |
56
- | `--agent-id` | no | Halo agent profile to use when ACP `session/new` creates a fresh session. Default `default` |
56
+ | `--agent-id` | no | Halo agent profile to use when ACP `session/new` creates a fresh session. Default `default` (alias `--agent`) |
57
57
 
58
58
  Each adapter process binds to **one** workspace. To drive multiple workspaces concurrently from one token, run multiple adapter processes — one per workspace.
59
59
 
@@ -98,7 +98,7 @@ The question is passed verbatim — including the other agent's own slash comman
98
98
  |---|---|---|
99
99
  | `initialize` | ✅ | Declares `protocolVersion: 1`, `loadSession: true`, no auth methods |
100
100
  | `authenticate` | ✅ (no-op) | Token is passed via launch flags; ACP-side auth has nothing to do |
101
- | `session/new` | ✅ | Mints a sessionId of shape `web_acp_<ts>_<rand>`. Halo creates the row lazily on first `/web/chat` |
101
+ | `session/new` | ✅ | Calls `POST /api/web/sessions`; halo mints `web_<accountId>_<ts>_<rand>` and creates the row immediately |
102
102
  | `session/load` | ✅ | Verifies the supplied id still exists on the halo server, then registers it locally |
103
103
  | `session/prompt` | ✅ | Forwards text + image content blocks. Resource / embedded-context blocks log a stderr warning and are dropped (see "Reverse fs" below) |
104
104
  | `session/cancel` | ✅ | Aborts the in-flight HTTP/SSE stream and POSTs `/web/stop` |
@@ -108,7 +108,7 @@ The question is passed verbatim — including the other agent's own slash comman
108
108
 
109
109
  ### Session id model
110
110
 
111
- ACP `sessionId` **is** the halo session id — there's no extra mapping layer. When `session/new` mints `web_acp_<ts>_<rand>`, that exact string is what gets created in `agent_sessions` (lazily on first `/web/chat`). The ACP client persists ids itself; the adapter holds no on-disk state. Losing the adapter's in-memory map on restart is harmless because the conversation lives on the halo server.
111
+ ACP `sessionId` **is** the halo session id — there's no extra mapping layer. `session/new` asks the server to mint one (`POST /api/web/sessions`); it lands in the token's own `web_<accountId>_` namespace and the `agent_sessions` row exists immediately, so readonly / workspace tokens can drive it too. The ACP client persists ids itself; the adapter holds no on-disk state. Losing the adapter's in-memory map on restart is harmless because the conversation lives on the halo server.
112
112
 
113
113
  The ACP client is the source of truth for "which sessions are mine" — a Mac-side Claude Code knows about *its* sessions, the EC2-side halo agent doesn't need to enumerate them.
114
114
 
@@ -93,7 +93,7 @@ secrets: []
93
93
 
94
94
  ### General — built-in
95
95
 
96
- Declared in [packages/server/src/settings-schema.ts](../../../packages/server/src/settings-schema.ts) `generalSection()`. The server itself is the implicit declarer. Keys: `language`, `agent.*`, `server.*`, `session.*`, `compact.*`, `sandbox.*`, `logging.*`, `evolution.*`, `limits.*`.
96
+ Declared in [packages/server/src/settings-schema.ts](../../../packages/server/src/settings-schema.ts) `generalSection()`. The server itself is the implicit declarer. Keys: `language`, `agent.*`, `server.*`, `session.*`, `compact.*`, `sandbox.*`, `logging.*`, `evolution.*`, `limits.*`. All `general.*` keys are `globalOnly`: `config.ts` resolves them through `settingsValue()` against `~/.halo/secrets/settings.yaml` only, so a workspace `settings.yaml` cannot override them. Per-workspace layering applies to namespaced `params` / `secrets` (`getServerSecret(ns, key, workspaceRoot)`, `substituteSecrets`).
97
97
 
98
98
  `server.trust_proxy` (boolean, default `false`, `globalOnly`): whether the brute-force rate limiter (`middleware/brute-force.ts` `getClientIp`) trusts the `x-forwarded-for` header for client IP resolution. Direct-connect deployments leave it `false` and get the socket address. Behind a reverse proxy (nginx / Cloudflare / etc.), set it to `true` so the real client IP is honored instead of the proxy's — but only when that proxy is one you control and rewrites the header itself, otherwise a client can forge XFF to dodge lockouts.
99
99
 
@@ -121,7 +121,7 @@ Declared in [packages/server/src/settings-schema.ts](../../../packages/server/sr
121
121
  | Global | `~/.halo/secrets/settings.yaml` | Base |
122
122
  | Workspace | `<project>/.halo/settings.yaml` | Overrides global, key by key |
123
123
 
124
- Read order: `<schema default> <- <global> <- <workspace>`.
124
+ Read order: `<schema default> <- <global> <- <workspace>` (workspace layer applies to namespaced `params` / `secrets` only — see General above).
125
125
 
126
126
  The Settings page shows source badges per field:
127
127
  - `workspace` (green-blue, override applied here)