@turmind/halo 1.1.7 → 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.
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/133-e2df20fc6362a02b.js +1 -0
- package/admin-out/_next/static/chunks/200.dd28be896c7765de.js +1 -0
- package/admin-out/_next/static/chunks/442.14eec8e615cc727b.js +1 -0
- package/admin-out/_next/static/chunks/515.b793a7455c5e8342.js +1 -0
- package/admin-out/_next/static/chunks/525.fe35ab8501121729.js +1 -0
- package/admin-out/_next/static/chunks/579.3ab0b2bb9244fc37.js +1 -0
- package/admin-out/_next/static/chunks/65.3aac567c42cec99c.js +1 -0
- package/admin-out/_next/static/chunks/896.2069ab525ef81643.js +1 -0
- package/admin-out/_next/static/chunks/935.91ddb965b44041c9.js +1 -0
- package/admin-out/_next/static/chunks/app/{layout-c2fa8035998e48d5.js → layout-e75df4cae90cf325.js} +1 -1
- package/admin-out/_next/static/chunks/app/page-ca15438e8bddb403.js +1 -0
- package/admin-out/_next/static/chunks/webpack-f80c9b8cca4447a3.js +1 -0
- package/admin-out/_next/static/css/{0d0ca7b3c8cc5562.css → f96c61f28fab6941.css} +1 -1
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/agentcore.md +1 -0
- package/bundled-docs/design/architecture.md +5 -2
- package/bundled-docs/design/background-dispatch.md +1 -1
- package/bundled-docs/design/cron.md +1 -1
- package/bundled-docs/design/feishu.md +2 -2
- package/bundled-docs/design/goal-mode.md +2 -0
- package/bundled-docs/design/halo-city.md +3 -1
- package/bundled-docs/design/prompt-system.md +12 -13
- package/bundled-docs/design/session.md +26 -5
- package/bundled-docs/design/storage.md +3 -2
- package/bundled-docs/design/web.md +12 -2
- package/bundled-docs/design/wechat.md +3 -2
- package/bundled-docs/design/ws.md +3 -3
- package/bundled-docs/dev/acp-adapter.md +5 -5
- package/bundled-docs/dev/api.md +5 -4
- package/bundled-docs/dev/deploy.md +2 -2
- package/bundled-docs/dev/desktop-packaging.md +8 -4
- package/bundled-docs/dev/env.md +3 -3
- package/bundled-docs/dev/tools.md +17 -7
- package/bundled-docs/guide/channels/acp.md +3 -3
- package/bundled-docs/guide/channels/wechat.md +1 -0
- package/bundled-docs/requirements/settings.md +3 -3
- package/dist/index.js +6647 -6405
- package/package.json +1 -1
- package/templates/agents/__evo_agent__/AGENT.md +13 -38
- package/templates/agents/goal/AGENT.md +29 -11
- package/templates/models/deepseek.yaml +6 -2
- package/templates/prompts/all/RUNTIME.md +16 -0
- package/templates/prompts/root/WORKSPACE_MEMORY.md +3 -0
- package/templates/skills/aws-knowledge/SKILL.md +3 -0
- package/templates/skills/cron/SKILL.md +14 -12
- package/templates/skills/halo/SKILL.md +1 -1
- package/templates/skills/web-search/SKILL.md +4 -1
- package/admin-out/_next/static/chunks/200.3b8f8a74f005a134.js +0 -1
- package/admin-out/_next/static/chunks/212-70bd2971392bde84.js +0 -1
- package/admin-out/_next/static/chunks/442.68ed26e27eb1d84f.js +0 -1
- package/admin-out/_next/static/chunks/515.4d98fae302278913.js +0 -1
- package/admin-out/_next/static/chunks/525.2c10a4fd329d0d24.js +0 -1
- package/admin-out/_next/static/chunks/579.a2718ddd395a2f3a.js +0 -1
- package/admin-out/_next/static/chunks/65.859665ba6717967a.js +0 -1
- package/admin-out/_next/static/chunks/896.ed21ec1e0846d20d.js +0 -1
- package/admin-out/_next/static/chunks/935.543aeec8456b6061.js +0 -1
- package/admin-out/_next/static/chunks/app/page-71c576815b4a30d9.js +0 -1
- package/admin-out/_next/static/chunks/webpack-fc1574554e7b1108.js +0 -1
- /package/admin-out/_next/static/{UspaVl1mRKHJayF8tgex9 → nVEORygQbBeOf2cFl82FW}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{UspaVl1mRKHJayF8tgex9 → 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` / `
|
|
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`.
|
|
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.
|
|
@@ -170,6 +170,9 @@ File: `ws/terminal-manager.ts`. Spawns a shell via node-pty. On disconnect, deta
|
|
|
170
170
|
### Self-Evolution — workspace prompt-tuning loop
|
|
171
171
|
Files: `evolution/{ticker,evo-wrapper,enqueue,spawn,archive}.ts`, `db/evo-db.ts`, `routes/evolution.ts`. Internal agents `__evo_agent__` / `__score__` / `__apply_agent__` (in `templates/agents/`) drive a 12-phase orchestration: snapshot → evo drafts → wrapper dry-runs → scorer grades → reviewer approves → apply agent merges → wrapper history-snapshots + cps to main. Per-task wrapper Node child process owns all sub-cli calls; ticker is stateless and lives in the server. State in `~/.halo/global/evo.db`. See [plans/self-evolution.md](../plans/self-evolution.md).
|
|
172
172
|
|
|
173
|
+
### Run ledger — restart nudge for interrupted roots
|
|
174
|
+
Files: `agents/run-ledger.ts` (sweep + skip rules) + `db/runs-db.ts` (the global `~/.halo/global/runs.db` table `running_sessions`, same singleton pattern as `evo.db`/`cron.db`). `runSession` inserts its session id on entry and deletes it in the finally (server-only, gated on `reconcileOrphansOnBoot`), so whatever is left at boot is exactly what the previous process was mid-run on; the sweep drains those rows and nudges each interrupted root. `index.ts` runs this eagerly: right after constructing the `SessionManagerRegistry` and before `bootChannels`, it loops `listRunningWorkspaces()` and claims+builds a `SessionManager` for each one that isn't already owned by another live server, rather than waiting for someone to open the workspace. See [design/session.md](session.md#run-ledger--restart-nudge-for-interrupted-roots-halo-globalrunsdb).
|
|
175
|
+
|
|
173
176
|
## REST routes
|
|
174
177
|
|
|
175
178
|
See [dev/api.md](../dev/api.md).
|
|
@@ -106,7 +106,7 @@ cleanupConnection
|
|
|
106
106
|
├─ flush every backgroundSaves
|
|
107
107
|
│ for (const [sid, saveFn] of backgroundSaves)
|
|
108
108
|
│ saveFn()
|
|
109
|
-
└─ stop
|
|
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
|
|
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 (
|
|
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'`
|
|
@@ -121,6 +121,8 @@ All five verbs are builtin deterministic code (`SUBCOMMAND_ROUTES`, no backing s
|
|
|
121
121
|
|
|
122
122
|
Continuation over death-handling: in-flight promises die with the process, goal state survives in the db. The SessionManager constructor (gated on `reconcileOrphansOnBoot` **and** the `.halo/runtime.lock` workspace claim — same ownership gate as the orphan reconcile) sweeps every goal at status `running` and delivers a deterministic nudge to G: *"server restarted, the in-flight round was lost; call goal_context, re-read GOAL_SPEC.md and your own transcript, re-dispatch."* Counters and caps were already in the db, so nothing is forgotten. `intake` needs no nudge (the user drives it); `paused` / `halted` / terminal goals stay put — a user-initiated pause still requires an explicit `/goal resume`.
|
|
123
123
|
|
|
124
|
+
Plain root sessions get the equivalent treatment from the run-ledger sweep (`sweepInterruptedRuns`, right after `sweepActiveGoals` in the same constructor chain — see [session.md](session.md#run-ledger--restart-nudge-for-interrupted-roots-halo-globalrunsdb)). A goal-bound W is excluded from that sweep **only** while its goal is `running` — that's the one state where this sweep above re-dispatches it, so a direct nudge would land a bogus round report on G. In `intake` or `paused`, G isn't re-dispatching anything, so W is nudged like any plain root.
|
|
125
|
+
|
|
124
126
|
## Admin surface & WS
|
|
125
127
|
|
|
126
128
|
- **`goal:changed`** is broadcast on every `writeGoalState` (and on the G-delete dissolve path) with `{goalSessionId, workerSessionId, status, round, maxRounds}`. The broadcast is **server-global with no workspace marker**; the admin re-fetches through the seed endpoint under its active project, which naturally filters cross-workspace events. See [ws.md](ws.md).
|
|
@@ -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
|
|
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 (`##
|
|
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
|
|
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
|
|
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")
|
|
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
|
-
**
|
|
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
|
-
##
|
|
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 ←
|
|
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
|
|
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
|
|
|
@@ -151,7 +152,21 @@ Hierarchical encoding: `root_id>child_segment>grandchild_segment`.
|
|
|
151
152
|
|
|
152
153
|
A sub-session whose process was killed mid-run never got its `stoppedAt` written, so it stays `stoppedAt IS NULL` forever — displaying as a false "running" and permanently blocking its parent's auto-report bubbling (`tryReportToParent` sees a "live" child that will never report back). When the server process first builds a workspace's SessionManager, `reconcileOrphansOnBoot` batch-stamps `stoppedAt` on every non-root, non-stopped, non-archived session. Only the long-lived server passes `reconcileOrphansOnBoot: true` through the registry — CLI/TUI/channel-subprocess/evo-wrapper share the same db while the server may be running sessions, so they never reconcile. If an orphan is later revived via `query_session`, that path clears `stoppedAt` again, so nothing is trapped permanently.
|
|
153
154
|
|
|
154
|
-
**Workspace-level gate (`.halo/runtime.lock`)**: `server.lock` ownership alone is not sufficient — two servers with different `HALO_HOME` (e.g. prod + dev) each hold their own `server.lock` yet can point at the *same* workspace directory, and one server's boot reconcile would batch-stop the other's actually-live sub-sessions (the incident this gate exists for). So the reconcile additionally requires `claimWorkspaceRuntime(workspaceRoot)` (`agents/workspace-runtime-lock.ts`) — a pid marker at `<workspace>/.halo/runtime.lock` with a liveness probe. Claim fails → skip reconcile and log a warning: **prefer missing a crash-orphan cleanup over stopping another process's live sessions**. `reconcileOrphansOnBoot: true` therefore means "reconcile if the workspace claim succeeds," not "always." Lock protocol details in [storage.md](storage.md#workspace-runtime-lock); known residual: when two servers both actively use one workspace long-term, the non-owner never reconciles — its own crash orphans stay un-cleaned until the owner restarts and takes over.
|
|
155
|
+
**Workspace-level gate (`.halo/runtime.lock`)**: `server.lock` ownership alone is not sufficient — two servers with different `HALO_HOME` (e.g. prod + dev) each hold their own `server.lock` yet can point at the *same* workspace directory, and one server's boot reconcile would batch-stop the other's actually-live sub-sessions (the incident this gate exists for). So the reconcile additionally requires `claimWorkspaceRuntime(workspaceRoot)` (`agents/workspace-runtime-lock.ts`) — a pid marker at `<workspace>/.halo/runtime.lock` with a liveness probe. Claim fails → skip reconcile and log a warning: **prefer missing a crash-orphan cleanup over stopping another process's live sessions**. `reconcileOrphansOnBoot: true` therefore means "reconcile if the workspace claim succeeds," not "always." Lock protocol details in [storage.md](storage.md#workspace-runtime-lock); known residual: when two servers both actively use one workspace long-term, the non-owner never reconciles — its own crash orphans stay un-cleaned until the owner restarts and takes over. The run-ledger eager boot loop below deals with the *un-opened* case for the same residual: a workspace whose runtime another live process owns is skipped before a SessionManager is even built, so the rows wait for a later boot instead of freezing behind a cached non-owner instance.
|
|
156
|
+
|
|
157
|
+
The constructor chain is now `reconcile orphans → sweepActiveGoals → sweepInterruptedRuns` — all three gated on the same `reconcileOrphansOnBoot` + `.halo/runtime.lock` claim, so a workspace that fails the claim runs none of them.
|
|
158
|
+
|
|
159
|
+
### Run ledger — restart nudge for interrupted roots (`~/.halo/global/runs.db`)
|
|
160
|
+
|
|
161
|
+
The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the **root** the server restarted — asked "done yet?" it still answers "waiting on reports" forever. Goal sessions already had a restart nudge (`sweepActiveGoals`); plain roots didn't, because "which session is running right now" only ever lived in memory.
|
|
162
|
+
|
|
163
|
+
**Ledger** (`db/runs-db.ts`): a global `~/.halo/global/runs.db` (same singleton pattern as `evo.db` / `cron.db` — `setRunsDb`/`getRunsDb`, WAL), one table `running_sessions(workspace TEXT, session_id TEXT, started_at INTEGER, PK(workspace, session_id))`. `runSession` inserts the id on entry and deletes it in its `finally`, so the steady state is an **empty table** — whatever is still there at boot is exactly the set of runs the previous process died in the middle of; no pid, no liveness probe needed. Only the long-lived server writes: `SessionManager.ledgerEnabled = opts.reconcileOrphansOnBoot === true`, the same flag that gates the orphan reconcile — cli / cron / TUI / evo-wrapper runs end with their terminal and never enter the table. Writes go through a private `ledgerWrite` wrapper that try/catches and only `console.warn('[RunLedger] ledger write failed …')` on failure: a sqlite hiccup (disk full, EIO) must not drop a message or strand a session at `promise = null` without a `complete` — worst case is one stale/missing row, i.e. one spurious or missed nudge at next boot.
|
|
164
|
+
|
|
165
|
+
**Sweep** (`sweepInterruptedRuns`, `agents/run-ledger.ts`): runs from the SessionManager constructor right after `sweepActiveGoals`, under the same ownership gate. `drainRunning(workspace)` reads and deletes that workspace's rows in one transaction **before** any nudge goes out — the invariant being that a nudge's own re-dispatch inserts a fresh row, which must never be mistaken for a leftover on some future sweep. Ids are grouped by root (`id.split('>')[0]`); each root gets one append-then-send nudge (text starts `[System] The server restarted at <ISO> while you were mid-run…`, telling it sub-agents are stopped-but-revivable via `query_session`; sub-sessions are not listed — the root's own transcript / `session_list` has them). Skipped per root: row missing or `archivedAt` set; `goal` column set (a goal session — `sweepActiveGoals` owns its nudge); a goal-bound worker whose goal is `running` (G's own restart nudge re-dispatches it; a direct nudge would land a bogus round report on G) — but **only** `running`: `intake`/`paused` workers are nudged like plain roots, safe because `sendUserMessage` bypasses the routing overlay and `deliverGoalRound` returns early for a non-running goal; `cron-*` ids; `internal: true` agents (synchronous read of `agent.yaml`, since the sweep runs from a sync constructor). Principle: exactly one entry point gets woken per tree — a plain tree wakes its root, a `running` goal tree wakes G, a cron tree wakes nobody (it waits for its next fire).
|
|
166
|
+
|
|
167
|
+
**Eager boot** (`index.ts`): rather than waiting for someone to open a workspace, right after constructing `SessionManagerRegistry` (before `bootChannels`) the server calls `listRunningWorkspaces()` and for each workspace with leftover rows whose `.halo` dir still exists: `claimWorkspaceRuntime(ws)` first — if another live server owns it, `continue` (rows stay for a later boot, and no SessionManager is built, since caching a non-owner SM would freeze "not owner" for the whole process lifetime) — otherwise `registry.getOrCreate(ws)`, which fires the same constructor chain (claim → reconcile → `sweepActiveGoals` → `sweepInterruptedRuns`). An interrupted root with nobody around is exactly the one that must nudge itself without a human opening the tab first.
|
|
168
|
+
|
|
169
|
+
`runs.db`/`-wal`/`-shm` join the sandbox `hidden_files` default alongside `evo.db` / `cron.db` (see [storage.md](storage.md)). Known gap carried from the design doc: `<ISO>` in the nudge is sweep time, not the actual interruption time — for a workspace that sat locked by another server before this boot could claim it, that's a real but accepted drift.
|
|
155
170
|
|
|
156
171
|
### Message queue and drain
|
|
157
172
|
|
|
@@ -159,7 +174,7 @@ A sub-session whose process was killed mid-run never got its `stoppedAt` written
|
|
|
159
174
|
|
|
160
175
|
A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is set for agent→agent entries (drives the `(from: session X)` prefix and the sibling-status suffix) and absent for user messages; `images` is the user multimodal payload (agent entries have none).
|
|
161
176
|
|
|
162
|
-
**runSession loop** (`runSession(sessionId, message)`, `message: string | ContentBlock[]`): runs the opening turn when there is one, then drains. An empty **string** message means "the work is already in `messageQueue`" (the `querySession` idle path) — it skips the opening turn and goes straight to drain. The per-turn reset (fresh `toolCallLog` / `warnedToolHashes`, `interruptRequested = false`) runs before the opening turn; `drainQueue` repeats the same reset before each merged batch so a stale interrupt flag never leaks across turns.
|
|
177
|
+
**runSession loop** (`runSession(sessionId, message)`, `message: string | ContentBlock[]`): runs the opening turn when there is one, then drains. An empty **string** message means "the work is already in `messageQueue`" (the `querySession` idle path) — it skips the opening turn and goes straight to drain. The per-turn reset (fresh `toolCallLog` / `warnedToolHashes`, `interruptRequested = false`) runs before the opening turn; `drainQueue` repeats the same reset before each merged batch so a stale interrupt flag never leaks across turns. Server-only: right before `session.promise = runFn()` it inserts the session id into the run ledger, and in the `finally` (right after `session.promise = null`, before emitting `complete`) it deletes it — see [Run ledger](#run-ledger--restart-nudge-for-interrupted-roots-halo-globalrunsdb) above.
|
|
163
178
|
|
|
164
179
|
**Arrival stamp.** Both paths run the turn through `runAgentTurn`, which prepends `[<ISO-8601 UTC>] ` (e.g. `[2026-09-12T15:17:44.153Z] `) to the first text block of the model-facing input — an image-only input gets a stamp-only text block unshifted. The model has no clock; without this it couldn't tell a reply that came two days later from one that came instantly, or how long a sub-agent report took to land. Stamped **once, before the retry loop**, so every attempt (and the [multimodal 4xx degrade](#multimodal-4xx-degrade) rebuild) reuses the identical input. Agent reports therefore read `[<iso>] (from: session X)\n…` — stamp outermost, no special-casing. Only `agent.messages` changes: the UI `type:'user'` event keeps the raw text (see [Turn matching](#exchange-deletion-soft-ui--hard-raw) for the strip on compare), and the self-compact instruction / `[Conversation Summary]` injection / `tool_result` messages are never stamped.
|
|
165
180
|
|
|
@@ -251,7 +266,7 @@ Status is derived from memory (`promise !== null`) — not stored.
|
|
|
251
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.
|
|
252
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.
|
|
253
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`).
|
|
254
|
-
- **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.
|
|
255
270
|
|
|
256
271
|
## Conversation repair
|
|
257
272
|
|
|
@@ -317,9 +332,9 @@ Frontend network issues don't affect the backend:
|
|
|
317
332
|
|
|
318
333
|
| Error | Recovery |
|
|
319
334
|
|---|---|
|
|
320
|
-
| User abort / graceful interrupt
|
|
335
|
+
| User abort / graceful interrupt — **our own `signal.aborted`** or `err.name === 'AbortError'` | Repair, clean exit |
|
|
321
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 |
|
|
322
|
-
| 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 |
|
|
323
338
|
| Rate limiting / throttling | Exponential backoff (`2s * 2^attempt` + jitter, capped at 60s: 2s/4s/8s/16s…), retry |
|
|
324
339
|
| **Transient server-side error (5xx / timeout)** | Same exponential backoff as throttling — see [Transient server-error classification](#transient-server-error-classification) below |
|
|
325
340
|
| Transient transport error (`fetch failed`, `ECONNRESET`, headers timeout, …) | Short backoff (`1s * 2^attempt` + jitter), retry |
|
|
@@ -327,6 +342,8 @@ Frontend network issues don't affect the backend:
|
|
|
327
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 |
|
|
328
343
|
| Unrecoverable error | Report to user, stop |
|
|
329
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
|
+
|
|
330
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.
|
|
331
348
|
|
|
332
349
|
### Multimodal 4xx degrade
|
|
@@ -374,6 +391,10 @@ All paths share the same split logic — the private `compactCut(messages)` on S
|
|
|
374
391
|
|
|
375
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.
|
|
376
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
|
+
|
|
377
398
|
Config (see `config.compact`): `keepMessages` / `maxSummaryInput` / `maxMessageSlice` / `summarizeTimeoutSec` — editable in Settings → General → compact.
|
|
378
399
|
|
|
379
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).
|
|
@@ -25,6 +25,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
25
25
|
│ │ └── <sessionId>.json
|
|
26
26
|
│ ├── evo.db # Cross-workspace evolution queue (evolution_runs + evolution_applies)
|
|
27
27
|
│ ├── cron.db # Cross-workspace cron jobs + run history (cron_jobs + cron_runs)
|
|
28
|
+
│ ├── runs.db # Run ledger: (workspace, session_id) rows for server-driven runs in flight; steady state empty (running_sessions)
|
|
28
29
|
│ ├── logs/ # Runtime logs
|
|
29
30
|
│ │ ├── evo/ # Per-evo-run wrapper logs
|
|
30
31
|
│ │ └── cron/ # Per-cron-run cli stdout/stderr (30-day retention)
|
|
@@ -327,7 +328,7 @@ general: # built-in declarer (the server itself
|
|
|
327
328
|
summarize_timeout_sec: 300
|
|
328
329
|
sandbox:
|
|
329
330
|
hidden_dirs: "~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh"
|
|
330
|
-
hidden_files: "~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc"
|
|
331
|
+
hidden_files: "~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/evo.db,~/.halo/global/evo.db-wal,~/.halo/global/evo.db-shm,~/.halo/global/cron.db,~/.halo/global/cron.db-wal,~/.halo/global/cron.db-shm,~/.halo/global/runs.db,~/.halo/global/runs.db-wal,~/.halo/global/runs.db-shm"
|
|
331
332
|
logging:
|
|
332
333
|
level: warn
|
|
333
334
|
|
|
@@ -383,7 +384,7 @@ A value of the form `<<ENV_NAME>>` is replaced at read time with `process.env.EN
|
|
|
383
384
|
| `general.limits.ws_event_buffer` | 5000 | Events buffered per detached WS session before oldest are dropped on reattach |
|
|
384
385
|
| `general.limits.terminal_scrollback_bytes` | 50000 | Off-screen scrollback bytes retained per detached persistent terminal |
|
|
385
386
|
| `general.sandbox.hidden_dirs` | `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh` | bwrap tmpfs overlay (Linux only) |
|
|
386
|
-
| `general.sandbox.hidden_files` | `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc` | bwrap /dev/null bind (Linux only) |
|
|
387
|
+
| `general.sandbox.hidden_files` | `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/evo.db,~/.halo/global/evo.db-wal,~/.halo/global/evo.db-shm,~/.halo/global/cron.db,~/.halo/global/cron.db-wal,~/.halo/global/cron.db-shm,~/.halo/global/runs.db,~/.halo/global/runs.db-wal,~/.halo/global/runs.db-shm` | bwrap /dev/null bind (Linux only) |
|
|
387
388
|
| `general.logging.level` | warn | `debug` / `info` / `warn` / `error` |
|
|
388
389
|
|
|
389
390
|
Schema source: [packages/server/src/settings-schema.ts](../../../packages/server/src/settings-schema.ts) `generalSection()`.
|
|
@@ -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
|
|
|
@@ -46,10 +46,11 @@ Sub-sessions inherit the parent's access level — a readonly channel can't dele
|
|
|
46
46
|
|
|
47
47
|
WeChat bot accounts are stored in the unified channel DB: `~/.halo/secrets/channels/channels.db`, table `channel_accounts` with `channel_type = 'wechat'`. See [storage.md](storage.md#channel_accounts) for the full schema.
|
|
48
48
|
|
|
49
|
-
WeChat-specific config JSON fields: `botToken`, `baseUrl`, `userId`, `syncBuf`, `lastActiveChatId`.
|
|
49
|
+
WeChat-specific config JSON fields: `botToken`, `baseUrl`, `userId`, `syncBuf`, `lastActiveChatId`, `contextTokens`.
|
|
50
50
|
|
|
51
51
|
- `userId` is the QR-bind owner (the ilink_user_id of whoever scanned the QR code to register this bot). Used by the wechat cron-dispatcher as the default proactive-send target — sending here means cron output goes back to the bot's owner, which is the common-case "report to me on a schedule" intent.
|
|
52
52
|
- `lastActiveChatId` is a runtime cache of the most recent inbound `from_user_id`. Written by `rememberLastActiveChat()` in `channels/shared/accounts.ts` with a per-process hash so unchanged values never touch the db (idempotent — hot path stays in memory). Used as a fallback for cron sends in the shared-bot case where you want to reply to whoever talked last.
|
|
53
|
+
- `contextTokens` is a `Record<userId, context_token>` — the most recent inbound `context_token` per user, written by `rememberWechatContextToken()` (same in-process dedupe / write-on-change shape as `lastActiveChatId`). The ilink gateway expects every outbound `sendmessage` to echo the recipient's latest inbound token; the chat-reply path already carries it in the in-memory bridge route, but cron dispatch runs long after the inbound and needs a persisted copy.
|
|
53
54
|
|
|
54
55
|
### Proactive sending (cron)
|
|
55
56
|
|
|
@@ -61,7 +62,7 @@ The wechat cron-dispatcher (`channels/wechat/cron-dispatcher.ts`) registers itse
|
|
|
61
62
|
|
|
62
63
|
WeChat is single-recipient per dispatch (no fan-out across `allowedUsers` like telegram has). If none of the three yields an id, dispatch fails with a clear "no wechat target — bind the account first" message.
|
|
63
64
|
|
|
64
|
-
The text is chunked exactly like a chat reply — `splitText(text, WECHAT_TEXT_LIMIT)` (3500 chars, see [Event coalescing](#event-coalescing-wechatresponder)) — and the chunks are sent sequentially so a long report arrives in order. Before this, a whole report went out in one `sendmessage` call and anything over the gateway's 16 KB ceiling failed outright with `ret=-2 "prepare failed"` (the stock-report job hit it routinely). A mid-chunk failure rethrows as `chunk i/n: <error>`, so the run row's dispatch result shows how much of the report already landed (chunks before `i` were delivered).
|
|
65
|
+
The text is chunked exactly like a chat reply — `splitText(text, WECHAT_TEXT_LIMIT)` (3500 chars, see [Event coalescing](#event-coalescing-wechatresponder)) — and the chunks are sent sequentially so a long report arrives in order. Before this, a whole report went out in one `sendmessage` call and anything over the gateway's 16 KB ceiling failed outright with `ret=-2 "prepare failed"` (the stock-report job hit it routinely). A mid-chunk failure rethrows as `chunk i/n: <error>`, so the run row's dispatch result shows how much of the report already landed (chunks before `i` were delivered). Every chunk carries the recipient's persisted `context_token` (`config.contextTokens[chatId]`, see above) — the ilink gateway expects outbound to echo the latest inbound token, and sending without it was the second `ret=-2` cause: 6 failures in 10 days on payloads far under 16 KB, one of them 6 minutes after the user had just been chatting with the bot, which rules out the earlier "no recent inbound message" theory. Accounts with no inbound since 1.1.8 have no token yet and still send bare (as before) until the owner's next message. `api.ts`'s `ret=-2` hint names both causes (oversized payload / missing token).
|
|
65
66
|
|
|
66
67
|
## Modules
|
|
67
68
|
|
|
@@ -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
|
|
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` | ✅ |
|
|
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:
|
|
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:
|
|
257
|
+
SESSION: web_<accountId>_<ts>_<rand>
|
|
258
258
|
---
|
|
259
259
|
本月(2026-05…)EC2 总花费 约 $1,846 …
|
|
260
260
|
```
|
package/bundled-docs/dev/api.md
CHANGED
|
@@ -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
|
|
|
@@ -34,7 +34,7 @@ pnpm --filter @turmind/halo-admin build # next build + copy-monaco; never a ba
|
|
|
34
34
|
|
|
35
35
|
## 2. Runtime data locations
|
|
36
36
|
|
|
37
|
-
No directory needs to be created by hand. SQLite databases are created automatically on first use: per-workspace state at `<workspace>/.halo/halo.db`, plus global queues at `~/.halo/global/evo.db` and `~/.halo/global/
|
|
37
|
+
No directory needs to be created by hand. SQLite databases are created automatically on first use: per-workspace state at `<workspace>/.halo/halo.db`, plus global queues at `~/.halo/global/evo.db`, `~/.halo/global/cron.db` and `~/.halo/global/runs.db`.
|
|
38
38
|
|
|
39
39
|
## 3. Run `halo setup`
|
|
40
40
|
|
|
@@ -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"
|
|
360
|
-
|
|
361
|
-
|
|
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
|