@turmind/halo 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/212-394c4fd40216e633.js +1 -0
  3. package/admin-out/_next/static/chunks/220.e5a2532bda77e816.js +1 -0
  4. package/admin-out/_next/static/chunks/4-e7ba0b1ced30190b.js +1 -0
  5. package/admin-out/_next/static/chunks/442.68ed26e27eb1d84f.js +1 -0
  6. package/admin-out/_next/static/chunks/515.4d98fae302278913.js +1 -0
  7. package/admin-out/_next/static/chunks/896.ed21ec1e0846d20d.js +1 -0
  8. package/admin-out/_next/static/chunks/935.543aeec8456b6061.js +1 -0
  9. package/admin-out/_next/static/chunks/app/page-86cb00582129ae76.js +1 -0
  10. package/admin-out/_next/static/chunks/webpack-fc1574554e7b1108.js +1 -0
  11. package/admin-out/_next/static/css/0d0ca7b3c8cc5562.css +3 -0
  12. package/admin-out/index.html +1 -1
  13. package/admin-out/index.txt +5 -5
  14. package/bundled-docs/design/agent.md +13 -1
  15. package/bundled-docs/design/agentcore.md +10 -0
  16. package/bundled-docs/design/architecture.md +28 -29
  17. package/bundled-docs/design/background-dispatch.md +30 -43
  18. package/bundled-docs/design/cron.md +19 -4
  19. package/bundled-docs/design/evolution.md +18 -2
  20. package/bundled-docs/design/feishu.md +19 -6
  21. package/bundled-docs/design/goal-mode.md +2 -2
  22. package/bundled-docs/design/halo-city.md +7 -0
  23. package/bundled-docs/design/session.md +53 -3
  24. package/bundled-docs/design/slack.md +20 -6
  25. package/bundled-docs/design/storage.md +39 -2
  26. package/bundled-docs/design/telegram.md +20 -8
  27. package/bundled-docs/design/web.md +1 -0
  28. package/bundled-docs/design/wechat.md +13 -17
  29. package/bundled-docs/design/ws.md +56 -13
  30. package/bundled-docs/dev/add-channel.md +49 -37
  31. package/bundled-docs/dev/api.md +158 -38
  32. package/bundled-docs/dev/deploy.md +48 -0
  33. package/bundled-docs/dev/desktop-packaging.md +41 -7
  34. package/bundled-docs/dev/env.md +3 -0
  35. package/bundled-docs/dev/previews.md +58 -3
  36. package/bundled-docs/dev/tools.md +13 -4
  37. package/bundled-docs/guide/channels/wechat.md +3 -3
  38. package/bundled-docs/guide/chat.md +1 -1
  39. package/bundled-docs/guide/cli.md +24 -5
  40. package/bundled-docs/guide/workspace.md +2 -2
  41. package/bundled-docs/requirements/chat.md +11 -2
  42. package/bundled-docs/requirements/editor.md +5 -2
  43. package/bundled-docs/requirements/explorer.md +1 -0
  44. package/bundled-docs/requirements/session.md +4 -3
  45. package/bundled-docs/requirements/settings.md +8 -0
  46. package/bundled-docs/requirements/source-control.md +1 -0
  47. package/dist/index.js +14092 -12842
  48. package/package.json +4 -1
  49. package/templates/agents/__apply_agent__/agent.yaml +1 -1
  50. package/templates/agents/__evo_agent__/agent.yaml +1 -1
  51. package/templates/agents/__score__/agent.yaml +1 -1
  52. package/templates/agents/deep-executor/agent.yaml +1 -1
  53. package/templates/agents/default/agent.yaml +1 -1
  54. package/templates/agents/goal/agent.yaml +1 -1
  55. package/templates/schema.sql +8 -10
  56. package/templates/skills/agent/SKILL.md +2 -2
  57. package/admin-out/_next/static/chunks/212-e217e00ce6cc2bdc.js +0 -1
  58. package/admin-out/_next/static/chunks/220.3dc41cd6269b88e6.js +0 -1
  59. package/admin-out/_next/static/chunks/397-fc5a406d80d46ca7.js +0 -1
  60. package/admin-out/_next/static/chunks/442.f4302087bddb1ec1.js +0 -1
  61. package/admin-out/_next/static/chunks/app/page-f8208e0c7f315faa.js +0 -1
  62. package/admin-out/_next/static/chunks/webpack-47520cdc55be63e7.js +0 -1
  63. package/admin-out/_next/static/css/80eca65cb72e5c69.css +0 -3
  64. /package/admin-out/_next/static/{WvYu_osMZVpohYN4ntkpS → lOrGs0dR-jmXz9PgabRBc}/_buildManifest.js +0 -0
  65. /package/admin-out/_next/static/{WvYu_osMZVpohYN4ntkpS → lOrGs0dR-jmXz9PgabRBc}/_ssgManifest.js +0 -0
@@ -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/*`, 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|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
  ## Health
8
8
 
@@ -17,8 +17,9 @@ File: `packages/server/src/middleware/auth.ts`
17
17
  | Method | Path | Purpose |
18
18
  |---|---|---|
19
19
  | POST | `/api/auth/login` | Password login, returns a JWT cookie |
20
- | POST | `/api/auth/logout` | Clears the auth cookie |
21
- | GET | `/api/auth/check` | Validates the current token; refreshes stale tokens |
20
+ | POST | `/api/auth/logout` | Clears the auth cookie (Set-Cookie `Max-Age=0`). In `PUBLIC_PATHS`. No server-side token blacklist — a copied JWT stays valid until natural expiry |
21
+ | GET | `/api/auth/check` | Validates the current token; refreshes stale tokens. Also returns `badge` (the `HALO_BADGE` env, `null` when unset) on both the 200 and the 401 so the login page can brand its favicon/title too |
22
+ | POST | `/api/auth/change-password` | Change the admin password. **Not** in `PUBLIC_PATHS` — requires a valid JWT cookie. Persists the new scrypt hash to `~/.halo/secrets/config.yaml`; takes effect immediately (no restart), does **not** rotate `jwt_secret` so existing sessions stay signed in |
22
23
 
23
24
  ## Files
24
25
 
@@ -30,7 +31,6 @@ File: `packages/server/src/routes/files.ts`
30
31
  | GET | `/api/files/search?projectId=&q=[&limit=][&dirsOnly=1]` | Recursive name search. Default limit 200, max 1000. Scans up to 50000 entries then truncates; returns `{matches, truncated}`. `dirsOnly=1` matches directories instead of files (powers the chat `@scope` directory completion). |
31
32
  | GET | `/api/files?path=&projectId=` | Read file content (max 10 MB) |
32
33
  | GET | `/api/files/stat?path=&projectId=` | Lightweight mtime + size |
33
- | GET | `/api/files/diff?path=&projectId=` | Git diff |
34
34
  | GET | `/api/files/download?path=&projectId=&inline=` | Download or inline-preview — streams the file (no full read into memory), supports `Range` (206 Partial Content) so `<video>`/`<audio>` can seek + partial-load; aborts the read if the client disconnects |
35
35
  | PUT | `/api/files` | Save file (body: `{path, content, projectId}`) |
36
36
  | POST | `/api/files/new` | Create empty file |
@@ -39,7 +39,9 @@ File: `packages/server/src/routes/files.ts`
39
39
  | POST | `/api/files/upload` | Multipart upload |
40
40
  | DELETE | `/api/files?path=&projectId=` | Delete file or directory (recursive) |
41
41
 
42
- All file operations validate the path stays inside the project root (prevents path traversal). One exception: `GET /api/files/download` also accepts absolute paths under `/tmp/` so that agent-produced working files (e.g. Playwright screenshots) can be inline-previewed from chat media chips.
42
+ **Inline Content-Type** for images comes from core's shared extension→MIME table (`imageMimeFromExt`, `packages/core/src/media/mime.ts`) on both `/api/files/download?inline=1` and `/api/web/file`; each route keeps only its own non-image entries (pdf / html / video / audio) and falls back to `application/octet-stream`. That table is why `.bmp` now serves as `image/bmp` on both — the two routes used to carry their own copies and `/api/web/file` returned `application/octet-stream` for it. `.svg` / `.ico` / `.avif` get a real image Content-Type when served, but are deliberately not "photos" (they're outside `IMAGE_EXTS`) so they never reach a vision model or a channel's sendPhoto.
43
+
44
+ All file operations validate the path stays inside the project root (prevents path traversal). The check (`validatePath` in `routes/workspace-path.ts`, shared with `data-preview.ts` and `git.ts`) realpaths both sides — longest existing ancestor plus the re-appended tail — so a symlink inside the workspace pointing outside it is rejected too, not just lexical `../`. One exception: `GET /api/files/download` also accepts absolute paths under `/tmp/` so that agent-produced working files (e.g. Playwright screenshots) can be inline-previewed from chat media chips.
43
45
 
44
46
  ### Filesystem browse (not project-scoped — for the workspace picker)
45
47
 
@@ -48,9 +50,29 @@ All file operations validate the path stays inside the project root (prevents pa
48
50
  | GET | `/api/fs/home` | Returns server `homedir()` — fallback when the frontend has no `?folder=` URL param. Returns `{ home }`. |
49
51
  | GET | `/api/fs/exists?path=/abs` | Validates an absolute path exists and whether it's a directory. Returns `{exists, isDirectory?}`. |
50
52
  | GET | `/api/fs/browse?path=/abs` | Lists immediate directory children (hidden ones dropped). Returns `{path, parent, entries: [{name, path}]}`. |
51
- | POST | `/api/fs/workspace/resolve` | Resolve `{path}` to an absolute path and run `ensureWorkspaceHalo()`. Returns `{id, path}`. Used by the workspace-picker on switch. |
53
+ | POST | `/api/fs/workspace/resolve` | Resolve `{path}` to an absolute path and run `ensureWorkspaceHalo()`. Returns `{id, path}`. Used by the workspace-picker on switch. 404 `path not found` when it doesn't exist; 400 `not a directory` for a file; 400 `filesystem root cannot be a workspace` for `/` (or a drive root) — `ensureWorkspaceHalo()` writes a whole `.halo/` scaffold, so those two shapes are refused before the write. Deeper system trees (`/usr`, `/etc`) are deliberately **not** banned. |
54
+
55
+ Absolute paths only. Purpose: Explorer's workspace picker and switching validation. `/api/fs/browse` is intentionally unrestricted (it's a read-only picker over the server's filesystem — same trust boundary as the admin cookie). Reading/writing files still goes through `/api/files/*` and remains project-sandboxed.
56
+
57
+ ## Data Preview
58
+
59
+ File: `packages/server/src/routes/data-preview.ts`
60
+
61
+ Tabular file previews (Parquet / SQLite / CSV / TSV) for the Canvas editor — parsed server-side, returned as schema + one page of JSON-safe rows so large files never travel to the browser.
62
+
63
+ | Method | Path | Purpose |
64
+ |---|---|---|
65
+ | GET | `/api/data-preview/sqlite/tables?path=&projectId=` | List tables. Returns `{ tables: [{name, rowCount}] }` |
66
+ | GET | `/api/data-preview/sqlite/rows?path=&projectId=&table=&offset=&limit=` | One page of rows. Returns `{ table, columns, rows, totalRows, offset, limit }` |
67
+ | GET | `/api/data-preview/parquet?path=&projectId=&offset=&limit=` | Schema + one page of rows. Returns `{ columns, rows, totalRows, offset, limit }` |
68
+ | GET | `/api/data-preview/csv?path=&projectId=&offset=&limit=` | Header + one page of data rows. Returns `{ columns, rows, totalRows, hasMore, offset, limit }` |
69
+
70
+ Pagination: `limit` defaults to 100, capped at 1000. Same `(path, projectId)` resolution + traversal guard as `files.ts` (400/403/404 with a readable `{error}` body, never a 500 stack trace).
52
71
 
53
- Absolute paths only. Purpose: Explorer's workspace picker and switching validation. Reading/writing files still goes through `/api/files/*` and remains project-sandboxed.
72
+ - **sqlite/tables**, **sqlite/rows** — opens the db `readonly` + `fileMustExist`, closed per request (no lingering lock on a live WAL db). Table names can't be parameterized, so they're gated on `sqlite_master` membership before being quoted into the query.
73
+ - **parquet** — reads only the footer + the row groups covering the requested page (hyparquet), never the whole file.
74
+ - **csv** — streaming RFC 4180 tokenizer, memory O(page). Delimiter is sniffed from the header line (`,` / `;` / tab, comma wins ties); `.tsv` forces tab. `totalRows` is a lazy lower bound while `hasMore` is true (exact once the scan reaches EOF, to avoid re-reading the whole file on every page turn). A single row over 1MB is rejected with 400 (delimiter-flood / non-CSV guard).
75
+ - All four fold cells to JSON scalars: bigints beyond ±2^53 become exact strings, BLOB/binary becomes a `<blob N bytes>` placeholder, `Date` becomes ISO text.
54
76
 
55
77
  ## Session Logs (unified)
56
78
 
@@ -61,34 +83,42 @@ Unified session log API — list + read session files across all agents.
61
83
  | Method | Path | Purpose |
62
84
  |---|---|---|
63
85
  | GET | `/api/sessions/logs?projectId=` | List session metadata, keyset-paginated. Default returns each top-level row + all descendants (sidebar tree); `rootOnly=1` returns roots only (chat-header dropdown) |
64
- | GET | `/api/sessions/logs/:id?projectId=` | Full session log (scans across agent dirs) |
65
- | DELETE | `/api/sessions/logs/:id?projectId=` | Delete the session log |
66
- | PATCH | `/api/sessions/logs/:id?projectId=` | Rename a session (admin-only) — updates the log file's title. Accepts any session id (root or sub-agent); the sidebar exposes the rename affordance on every row |
86
+ | GET | `/api/sessions/logs/:id?projectId=` | Full session log (scans across agent dirs) — the **active** file only; archived history is a separate call |
87
+ | GET | `/api/sessions/logs/:id/archive/:n?projectId=` | One archived UI-log segment — see [detail](#get-apisessionslogsidarchivenprojectidabs) |
88
+ | DELETE | `/api/sessions/logs/:id?projectId=` | Delete the session log (and all of its archive segments) |
89
+ | PATCH | `/api/sessions/logs/:id?projectId=` | Rename a session (admin-only) — updates the log file's title and the mirrored `agent_sessions.title`. Accepts any session id (root or sub-agent); the sidebar exposes the rename affordance on every row |
67
90
  | GET | `/api/sessions/goal?projectId=` | Latest goal binding for the workspace (goal-mode banner / input-lock seed) — see [detail](#get-apisessionsgoalprojectidabs) |
68
91
 
69
- The list endpoint returns flat metadata (id / agentId / agentName / title / timestamps / messageCount / parentSessionId / stoppedAt / contextTokens / totalOutputTokens / goalSessionId). The frontend builds the tree from `parentSessionId`; `goalSessionId` (non-null on a goal-bound worker row) drives the 🎯 badge.
92
+ The list endpoint returns flat metadata (id / agentId / agentName / title / timestamps / exchangeCount / parentSessionId / stoppedAt / contextTokens / totalOutputTokens / goalSessionId), served from the mirrored `agent_sessions` columns rather than by parsing each session file (rows predating the columns are backfilled from the file on first read). The frontend builds the tree from `parentSessionId`; `goalSessionId` (non-null on a goal-bound worker row) drives the 🎯 badge.
70
93
 
71
94
  The get endpoint returns the full session file. If only `rawMessages` is present (no event-log `messages`), `convertRawMessages()` transforms it into display format on the fly.
72
95
 
73
- ### Legacy DB sessions
96
+ ## Channel accounts — shared field validation
74
97
 
75
- | Method | Path | Purpose |
98
+ `workspacePath` and `accessLevel` are validated at the REST boundary on both
99
+ POST and PATCH across all five channels (web / wechat / telegram / slack /
100
+ feishu) — one shared pair of checks in `channels/shared/accounts.ts`, so the
101
+ five can't drift (they did: four PATCHes never checked `isAbsolute`, and only
102
+ wechat's PATCH rejected an unknown `accessLevel`):
103
+
104
+ | Field | Rule | 400 body |
76
105
  |---|---|---|
77
- | GET | `/api/sessions?projectId=` | List sessions (DB, legacy) |
78
- | GET | `/api/sessions/:id` | Full session (DB, legacy) |
79
- | DELETE | `/api/sessions/:id` | Delete session (DB, legacy) |
106
+ | `accessLevel` | Must be one of the channel's legal levels when present; absent = leave unchanged. **web** allows `full` / `workspace` / `readonly` / `observer` (halo-city / metrics tokens are minted here); the **four chat channels** allow only `full` / `workspace` / `readonly` — `observer` is a dashboard role, not a chat identity | `{error: "accessLevel must be one of: …"}` |
107
+ | `workspacePath` | Absolute and existing. Empty string / relative path → 400; PATCH treats *absence* as "leave the binding alone" but validates any value supplied. On success `ensureWorkspaceHalo()` scaffolds `.halo/` | `{error: "workspacePath must be absolute"}` / `{error: "workspace path not found"}` |
108
+
109
+ POST additionally rejects a missing `workspacePath` with `{error: "workspacePath required"}`. Stored levels are still normalized to `readonly` on read, but the db no longer holds a value no code path agrees with.
80
110
 
81
- ## Weixin Channel
111
+ ## WeChat Channel
82
112
 
83
- File: `packages/server/src/routes/weixin.ts`
113
+ File: `packages/server/src/routes/wechat.ts`
84
114
 
85
115
  | Method | Path | Purpose |
86
116
  |---|---|---|
87
- | POST | `/api/weixin/login/start` | Start the QR login flow, returns `{qrcodeUrl, sessionKey}` |
88
- | POST | `/api/weixin/login/wait` | Poll QR status. body: `{sessionKey, workspacePath, label?, language?}`; on success the account is inserted and long-polling starts |
89
- | GET | `/api/weixin/accounts` | List every bot account |
90
- | PATCH | `/api/weixin/accounts/:id` | Change label / workspacePath / enabled / accessLevel / language |
91
- | DELETE | `/api/weixin/accounts/:id` | Stop long-polling and delete the DB row |
117
+ | POST | `/api/wechat/login/start` | Start the QR login flow, returns `{qrcodeUrl, sessionKey}` |
118
+ | POST | `/api/wechat/login/wait` | Poll QR status. body: `{sessionKey, workspacePath, label?, accessLevel?, language?}`; on success the account is inserted and long-polling starts. `workspacePath` must be absolute — checked **before** the blocking QR wait so a typo is rejected without asking the user to scan; existence + `.halo/` scaffolding are only checked after the scan lands, so an abandoned login never scaffolds a directory |
119
+ | GET | `/api/wechat/accounts` | List every bot account |
120
+ | PATCH | `/api/wechat/accounts/:id` | Change label / workspacePath / enabled / accessLevel / language |
121
+ | DELETE | `/api/wechat/accounts/:id` | Stop long-polling and delete the DB row |
92
122
 
93
123
  See [design/wechat.md](../design/wechat.md).
94
124
 
@@ -103,7 +133,7 @@ File: `packages/server/src/routes/telegram.ts`. Admin cookie auth. Mounted under
103
133
  | PATCH | `/api/telegram/accounts/:id` | Update `label` / `workspacePath` / `enabled` / `accessLevel` / `allowedUsers` / `language`. Stops then restarts the bot if still enabled |
104
134
  | DELETE | `/api/telegram/accounts/:id` | Stop long-poll and delete the row |
105
135
 
106
- `workspacePath` must be absolute and exist; `ensureWorkspaceHalo()` runs on POST/PATCH.
136
+ `workspacePath` / `accessLevel` follow the [shared validation rules](#channel-accounts--shared-field-validation).
107
137
 
108
138
  ## Slack Channel
109
139
 
@@ -139,7 +169,7 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
139
169
  |---|---|---|
140
170
  | GET | `/api/cron/jobs?limit=&before=` | Cursor-paginated newest-first by `createdAt`. Default `limit` 20, max 100; `before` is a `createdAt` cursor. Returns `{jobs, hasMore, nextCursor}`. Each job has `targets` decoded and a live `nextRunAt` (computed from schedule/runAt) |
141
171
  | POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt, schedule, runAt?, timezone?, targets?, enabled?}`. Exactly one of `schedule` (5-field cron) or `runAt` (epoch ms, future) is required; mutually exclusive. Returns `{ok, id}` |
142
- | PUT | `/api/cron/jobs/:id` | Partial update over the same fields. Re-validates `schedule` / `runAt`; flips schedule/unschedule on `enabled`. Returns `{ok}` |
172
+ | PUT | `/api/cron/jobs/:id` | Partial update over the same fields. Re-validates `schedule` / `runAt` and enforces trigger-mode exclusivity on the **merged** row: both in one body → 400 `schedule and runAt are mutually exclusive`; supplying one side is a mode switch that implicitly clears the other; a body that clears the active side without supplying a replacement → 400 `schedule or runAt required`. `runAt` must be a finite future epoch-ms number or `null`. Flips schedule/unschedule on `enabled`. Returns `{ok}` |
143
173
  | DELETE | `/api/cron/jobs/:id` | Unschedule, delete every `cron_runs` row for the job, then delete the job. Returns `{ok}` |
144
174
  | POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget). Returns `{ok}` |
145
175
  | POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
@@ -162,14 +192,14 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
162
192
 
163
193
  ## Source Control (Git)
164
194
 
165
- File: `packages/server/src/routes/git.ts`. Admin cookie auth. Backs the Source Control panel + Explorer git decorations. All project-scoped reads take `projectId=<absPath>`; writes take it in the JSON body. A write (stage/unstage/commit/push/pull/init/remote) broadcasts `file:changed` (path `.git`) so the panel, graph and explorer decorations auto-refresh without polling — the file watcher ignores `.git`, so the route re-broadcasts it itself. Path params on diff are traversal-checked against the project root.
195
+ File: `packages/server/src/routes/git.ts`. Admin cookie auth. Backs the Source Control panel + Explorer git decorations. All project-scoped reads take `projectId=<absPath>`; writes take it in the JSON body. A write (stage/unstage/commit/push/pull/init/remote) broadcasts `file:changed` (path `.git`) so the panel, graph and explorer decorations auto-refresh without polling — the file watcher ignores `.git`, so the route re-broadcasts it itself. The broadcast is **workspace-scoped** (`broadcastToWorkspace`): only clients bound to that workspace get it, so a git write in A no longer makes every tab showing B refetch status + ignored + log. Path params on diff are traversal-checked against the project root.
166
196
 
167
197
  ### Repo state (read)
168
198
 
169
199
  | Method | Path | Purpose |
170
200
  |---|---|---|
171
201
  | GET | `/api/git/status?projectId=` | Structured working-tree status. A folder that isn't a git work-tree **root** (no repo, or merely nested inside an ancestor's repo) returns `{isRepo: false}` (200, not 500) so the panel shows its initialize/empty state. Otherwise `{isRepo: true, branch, tracking, ahead, behind, files: [{path, index, workingDir, from?}]}` — `index`/`workingDir` are the two porcelain status chars (X staged / Y working) |
172
- | GET | `/api/git/ignored?projectId=` | `.gitignore`'d paths for graying out in the explorer; ignored directories are collapsed to a single entry (e.g. `node_modules`) rather than expanded. Returns `{ignored: string[]}`. Runs with `core.quotepath=false` so non-ASCII (e.g. Chinese) paths come back literal, not octal-escaped — the frontend prefix-matches them against tree node paths |
202
+ | GET | `/api/git/ignored?projectId=` | `.gitignore`'d paths for graying out in the explorer; ignored directories are collapsed to a single entry (e.g. `node_modules`) rather than expanded. Returns `{ignored: string[]}` (`{isRepo:false, ignored:[]}` when the folder isn't a work-tree root — same guard as `status`, applied to all six read endpoints so a workspace nested inside an ancestor's repo never leaks its state). Runs with `core.quotepath=false` so non-ASCII (e.g. Chinese) paths come back literal, not octal-escaped — the frontend prefix-matches them against tree node paths |
173
203
  | GET | `/api/git/diff?projectId=&path=[&staged=0\|1][&from=][&commit=]` | Two sides for the Monaco diff editor. With `commit`, shows that commit's own change (parent vs commit); without it, the working-tree (or `staged=1`) diff. `from` carries the old path on a rename. Returns `{path, original, modified, ...}`. 400 if `path` missing, 403 on traversal |
174
204
  | GET | `/api/git/log?projectId=[&limit=50]` | Recent commits for the graph. `limit` default 50, capped at 2000. Returns `{commits: [{hash, shortHash, message, author, date, refs, pushed}]}` — `pushed: boolean` marks whether the commit is on a remote (graph dims pushed nodes gray, paints local-only ones blue). No-upstream branches compute it via `rev-list HEAD --not --remotes`, not `rev-list HEAD` |
175
205
  | GET | `/api/git/commit-files?projectId=&hash=` | Files changed by one commit. Returns `{files: [{path, status, from?}]}` (`status` = M/A/D/R/C; `from` set on rename/copy). 400 if `hash` missing |
@@ -177,6 +207,8 @@ File: `packages/server/src/routes/git.ts`. Admin cookie auth. Backs the Source C
177
207
 
178
208
  ### Mutations (write — each broadcasts `file:changed`)
179
209
 
210
+ Every write except `/api/git/init` is gated on the project being a git work-tree **root** (`getGitForWrite` → `isRepoRoot()`): a folder that's merely nested inside an ancestor's repo gets **409** `{error: 'Not a git repository root'}` instead of silently mutating the ancestor. Reads keep their 200-with-empty-payload shape for the same state. `/api/git/init` is deliberately ungated — initializing a nested folder's own repo is exactly its purpose.
211
+
180
212
  | Method | Path | Purpose |
181
213
  |---|---|---|
182
214
  | POST | `/api/git/stage` | Body `{projectId, paths: string[]}`. `git add`. 400 if `paths` empty |
@@ -217,6 +249,21 @@ File: `packages/server/src/routes/web.ts`
217
249
 
218
250
  ### Public endpoints (token auth via `x-token` header or `?token=` query)
219
251
 
252
+ Token auth for the whole public surface — these routes, halo-city's
253
+ `/api/show/*`, and `/api/metrics` — runs through one shared core,
254
+ `resolveTokenAuth` in `middleware/web-token.ts`: header/query token parsing,
255
+ account lookup (must exist **and** be enabled), and the per-IP brute-force
256
+ bookkeeping in one place (5 strikes / 15 min lockout, one `web-token` bucket
257
+ shared by the three surfaces, independent of admin-login lockouts). A missing
258
+ token is a plain 401 and **not** a strike; only an invalid one counts.
259
+
260
+ Statuses are identical everywhere (`locked_out` 429, `missing_token` /
261
+ `invalid_token` 401) — only the **body shape** differs, which is why each surface
262
+ maps the failure itself: web + `/api/show/*` share the JSON renderer
263
+ (`{error: "token required" | "invalid token" | "too many failed attempts, try again later"}`),
264
+ `/api/metrics` answers in Prometheus comment lines (`# …`) so a scraper never
265
+ gets JSON, and additionally gates on `accessLevel`.
266
+
220
267
  | Method | Path | Purpose |
221
268
  |---|---|---|
222
269
  | 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`) |
@@ -229,8 +276,9 @@ See [design/web.md](../design/web.md).
229
276
 
230
277
  ## Show (world snapshot)
231
278
 
232
- File: `packages/server/src/routes/show.ts`. Token auth (same `x-token` as the
233
- Web channel; shares its brute-force lockout bucket). Read-only, cross-workspace
279
+ File: `packages/server/src/routes/halo-city.ts`. Token auth (same `x-token` as
280
+ the Web channel — same shared `resolveTokenAuth` core, same JSON error shape,
281
+ same brute-force lockout bucket). Read-only, cross-workspace
234
282
  snapshot powering the `halo-city` pixel visualizer — one call returns the whole
235
283
  runtime so the frontend can render rooms (workspaces) + characters (sessions).
236
284
 
@@ -241,7 +289,7 @@ runtime so the frontend can render rooms (workspaces) + characters (sessions).
241
289
 
242
290
  `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.
243
291
 
244
- Each `workspace` = `{ path, key, label, counts{running,idle,stopped}, totalSessions, skills[], sessions[] }`; each `session` = `{ id, parentId, depth, agentName, description, status, lastTool, activeSkill, contextTokens, outputTokens, messageCount, updatedAt }`. `lastTool` / `activeSkill` come from the live in-memory UI log (empty when the session isn't loaded). Sessions per room are capped (`totalSessions` reports the true total). Frontend: `halo-city/` at repo root.
292
+ Each `workspace` = `{ path, key, label, counts{running,idle,stopped}, totalSessions, skills[], sessions[] }`; each `session` = `{ id, parentId, depth, agentName, description, status, lastTool, activeSkill, contextTokens, outputTokens, messageCount, updatedAt }`. `messageCount` here is the session file's raw UI-log length (an activity signal for the citizen animation) — deliberately **not** the admin list's lifetime `exchangeCount`; it shrinks when a compact archives history. `lastTool` / `activeSkill` come from the live in-memory UI log (empty when the session isn't loaded). Sessions per room are capped (`totalSessions` reports the true total). Frontend: `halo-city/` at repo root.
245
293
 
246
294
  **Read-only by construction — degraded snapshots.** Both endpoints resolve a workspace's runtime via `registry.peek()` — in-memory lookup only, **never** `getOrCreate`: constructing a SessionManager has write side effects (boot orphan-reconciliation batch-stops live sub-session rows; `.halo/` scaffolding), so a pure visualization poll must not trigger it. When no live SessionManager exists in this process, the handler opens that workspace's `.halo/halo.db` **read-only** (cached connection; missing db/tables silently degrade to an empty room) and projects rows / the persisted session file into the same wire shape. Sessions not in memory therefore return a **persisted snapshot**: `status` / token counts / `messageCount` may lag behind the last persistence point, and the live-only signals (`lastTool` / `activeSkill`) are empty by definition. The degraded `/api/show/session` path reports `maxContextTokens: 0` (the real cap needs a live agent config; the city inspector hides the meter when the cap is unknown).
247
295
 
@@ -268,6 +316,8 @@ File: `packages/server/src/routes/agent-configs.ts`
268
316
  | DELETE | `/api/agent-configs/:id/sessions/:sessionId` | Delete a session |
269
317
  | DELETE | `/api/agent-configs/:id/sessions?all=1` | Delete every session for the agent |
270
318
 
319
+ Every `:id` / `:sessionId` route param that becomes a path segment (here and in `DELETE /api/skills/:id`) is validated with `isSafeIdSegment()` (`routes/workspace-path.ts`) before it reaches `path.join` — `.` / `..` rejected outright, everything else must match `/^[\w.:>\u4e00-\u9fff-]+$/` (the union of the real id charsets: slug + CJK agent names, `__internal__`, and session ids embedding `_ - : . >`). Hono decodes `%2F` / `%2e` into params, so a raw `..%2F..%2Fetc` would otherwise arrive as a traversal. A rejected param is **400** (`Invalid agent id` / `Invalid skill id` / `Invalid session id`), distinct from the **404** a well-formed-but-missing session file gets.
320
+
271
321
  ## Skills
272
322
 
273
323
  File: `packages/server/src/routes/skills.ts`
@@ -300,6 +350,8 @@ File: `packages/server/src/routes/settings.ts`
300
350
  | PATCH | `/api/settings` | Update a single key |
301
351
  | DELETE | `/api/settings` | Delete a key |
302
352
 
353
+ PATCH / DELETE walk the dotted key layer by layer, so a segment of `__proto__` / `constructor` / `prototype` is rejected with 400 `Invalid key` — the leaf assignment would otherwise land on `Object.prototype` and pollute every object in the process.
354
+
303
355
  ## Metrics
304
356
 
305
357
  File: `packages/server/src/routes/metrics.ts`.
@@ -308,7 +360,7 @@ File: `packages/server/src/routes/metrics.ts`.
308
360
  |---|---|---|
309
361
  | GET | `/api/metrics` | Prometheus text exposition (gauges) of deployment-wide runtime: `halo_uptime_seconds`, `halo_workspaces`, `halo_sessions{status=}`, `halo_sessions_total`, `halo_context_tokens`, `halo_output_tokens` |
310
362
 
311
- In `PUBLIC_PATHS` (no admin cookie). Auth is a web-channel `x-token` (header or `?token=`) with a **global** scope — `full` or `observer` (the read-only role minted for dashboards/scrapes); a workspace-scoped token gets 403. Session counts come from each workspace's `listSessions`; token sums read the in-memory UIState of sessions this process actively drives.
363
+ In `PUBLIC_PATHS` (no admin cookie). Auth is a web-channel `x-token` (header or `?token=`) resolved through the shared `resolveTokenAuth` core, but rendered as Prometheus comment lines instead of JSON — and it additionally requires a **global** scope: `full` or `observer` (the read-only role minted for dashboards/scrapes); a workspace-scoped token gets `# global-scope token (full or observer) required` with 403. Session counts come from each workspace's `listSessions`; token sums read the in-memory UIState of sessions this process actively drives.
312
364
 
313
365
  ## Self-Evolution
314
366
 
@@ -354,10 +406,36 @@ Source: [packages/server/src/middleware/auth.ts:123-134](../../../packages/serve
354
406
 
355
407
  ```json
356
408
  // 200 authenticated
357
- { "authenticated": true }
409
+ { "authenticated": true, "badge": "DEV" }
358
410
 
359
411
  // 401 not authenticated
360
- { "authenticated": false }
412
+ { "authenticated": false, "badge": "DEV" }
413
+ ```
414
+
415
+ `badge` echoes the `HALO_BADGE` env (trimmed; `null` when unset) on **both**
416
+ branches — this is the admin's first request, login page included, so the tab
417
+ can brand its favicon + title from the very first paint without a second
418
+ endpoint or a poll. Corollary: the value is readable **without authenticating**,
419
+ so it must stay a label, not a secret. See [env.md](./env.md).
420
+
421
+ ### POST `/api/auth/change-password`
422
+
423
+ Source: [packages/server/src/middleware/auth.ts](../../../packages/server/src/middleware/auth.ts). Admin-cookie authed (not in `PUBLIC_PATHS`). Validation order: wrong current password → 401; strength (≥8 chars, at least one letter and one digit) → 400 with the specific reason; new === old → 400. When `HALO_PASSWORD` env is set it overrides the stored hash at login, so a file rewrite would silently not take effect — the endpoint rejects with 400 instead.
424
+
425
+ ```json
426
+ // Request
427
+ { "oldPassword": "...", "newPassword": "..." }
428
+
429
+ // 200 OK — new scrypt hash persisted to ~/.halo/secrets/config.yaml,
430
+ // live for the next login without restart; jwt_secret is NOT rotated
431
+ // (existing cookies stay valid)
432
+ { "ok": true }
433
+
434
+ // 401 wrong current password
435
+ { "error": "Incorrect current password" }
436
+
437
+ // 400 strength / same-as-old / env-managed (message names the reason)
438
+ { "error": "Password must be at least 8 characters" }
361
439
  ```
362
440
 
363
441
  ### GET `/api/files/tree?projectId=<absPath>&path=<relPath>`
@@ -496,7 +574,7 @@ Two shapes, selected by `rootOnly`:
496
574
  "source": "explorer",
497
575
  "createdAt": "2026-04-30T08:00:00Z",
498
576
  "updatedAt": "2026-04-30T08:05:00Z",
499
- "messageCount": 12,
577
+ "exchangeCount": 12,
500
578
  "parentSessionId": null,
501
579
  "contextTokens": 5975,
502
580
  "totalOutputTokens": 6058,
@@ -510,9 +588,11 @@ Two shapes, selected by `rootOnly`:
510
588
 
511
589
  Archived sessions are excluded by default; pass `?includeArchived=1` to include them.
512
590
 
591
+ Rows are served from the mirrored `agent_sessions` metadata columns (`title` / `exchange_count` / `context_tokens` / `total_output_tokens`) — no session file is opened. A row whose `exchangeCount` is `null` predates those columns: the route reads that one file once, mirrors the values back, and never pays the cost again. `exchangeCount` counts **main user turns over the session's lifetime** (kept + archived), so unlike the file's `messageCount` it doesn't shrink when a compact archives history.
592
+
513
593
  ### GET `/api/sessions/logs/:id?projectId=<abs>`
514
594
 
515
- Returns the full session file. If only `rawMessages` is present (sub-agent sessions from SessionManager), `convertRawMessages()` transforms them into the display `messages` array on the fly.
595
+ Returns the full session file — the **active** log only. Older exchanges that archiving moved out are fetched per-segment via [the archive endpoint](#get-apisessionslogsidarchivenprojectidabs). If only `rawMessages` is present (sub-agent sessions from SessionManager), `convertRawMessages()` transforms them into the display `messages` array on the fly.
516
596
 
517
597
  ```json
518
598
  // 200 — same shape as the on-disk JSON
@@ -527,11 +607,38 @@ Returns the full session file. If only `rawMessages` is present (sub-agent sessi
527
607
  "messageCount": 12,
528
608
  "contextTokens": 5975,
529
609
  "totalOutputTokens": 6058,
610
+ "archiveCount": 2,
530
611
  "messages": [ /* SessionMessage[] — see design/storage.md */ ],
531
612
  "rawMessages": [ /* optional */ ]
532
613
  }
533
614
  ```
534
615
 
616
+ `messageCount` here is the **file's own** field (`messages.length`, shrinks on archive) — not the list endpoint's lifetime `exchangeCount`.
617
+
618
+ ### GET `/api/sessions/logs/:id/archive/:n?projectId=<abs>`
619
+
620
+ Source: `packages/server/src/routes/session-archive.ts`
621
+
622
+ One archived UI-log segment, gunzipped server-side. `n` counts up from 1; the client gets its anchor from `archiveCount` on the `state:snapshot` of subscribe/reattach ([ws.md](../design/ws.md)) and walks **down** from it. Segments are immutable once committed, so a client caches what it pulled and never re-requests. Admin-cookie gated (not in `PUBLIC_PATHS`).
623
+
624
+ ```json
625
+ // 200
626
+ { "messages": [ /* SessionMessage[] — oldest-first within the segment */ ] }
627
+ ```
628
+
629
+ Failure modes, in the order checked:
630
+
631
+ | Status | Body | When |
632
+ |---|---|---|
633
+ | 500 | `session manager not initialized` | server not fully booted |
634
+ | 400 | `projectId required` | missing query param |
635
+ | 400 | `Invalid session id` | `id` fails `isSafeIdSegment` (path-traversal guard) |
636
+ | 400 | `Invalid segment number` | `n` not a positive integer |
637
+ | 404 | `Session not found` | no such session in this workspace |
638
+ | 404 | `Segment not found` | `n > archiveCount` (uncommitted / nonexistent), or the segment file can't be read |
639
+
640
+ The `n > archiveCount` check is the point, not a formality: `archiveCount` in the active file is the archive write's commit marker, so a segment file beyond it exists only because a crash interrupted the two-step write and must never be served. See [design/session.md](../design/session.md#ui-log-archiving).
641
+
535
642
  ### GET `/api/sessions/goal?projectId=<abs>`
536
643
 
537
644
  Source: `packages/server/src/routes/sessions.ts` (`findLatestGoal` in `agents/goal-mode.ts`)
@@ -608,9 +715,9 @@ Resolves declared schema (from `models/<id>.yaml` `secrets:` and `skills/<id>/co
608
715
 
609
716
  PUT replaces the full scope; DELETE takes `{scope, projectId, key}` and removes the key. The Settings page uses DELETE for both Reset (current scope removed → falls back to lower scope / unset) and orphan Remove.
610
717
 
611
- ### POST `/api/weixin/login/start`
718
+ ### POST `/api/wechat/login/start`
612
719
 
613
- Source: [packages/server/src/routes/weixin.ts:42-46](../../../packages/server/src/routes/weixin.ts#L42-L46)
720
+ Source: [packages/server/src/routes/wechat.ts:39-43](../../../packages/server/src/routes/wechat.ts#L39-L43)
614
721
 
615
722
  ```json
616
723
  // Request
@@ -620,7 +727,7 @@ Source: [packages/server/src/routes/weixin.ts:42-46](../../../packages/server/sr
620
727
  { "qrcodeUrl": "https://...", "sessionKey": "abc123" }
621
728
  ```
622
729
 
623
- ### POST `/api/weixin/login/wait`
730
+ ### POST `/api/wechat/login/wait`
624
731
 
625
732
  ```json
626
733
  // Request
@@ -683,6 +790,18 @@ Attach this connection to a session's event stream. Sent on initial connect and
683
790
  }
684
791
  ```
685
792
 
793
+ Idempotent per connection: re-subscribing to the same session releases this connection's previous listener and registers exactly one new one — safe to repeat. The client must also re-send it on receiving `listener:released` (see below).
794
+
795
+ ### `listener:released` (S→C)
796
+
797
+ The server reclaimed this connection's event listener — sent when the connection was silent for >3 min (no inbound frames; e.g. a frozen tab whose network stack still answers protocol pings) or found CLOSED with a listener attached. The socket stays open; only the event stream is detached.
798
+
799
+ ```json
800
+ { "type": "listener:released", "sessionId": "sid_abc" }
801
+ ```
802
+
803
+ On receipt the client re-sends `subscribe` to reattach — this frame is the only recovery signal a frozen-and-resumed tab gets, because the server's `__pong__` replies keep the client's own staleness detection from ever firing. Any WS consumer must send `__ping__` periodically (any inbound frame counts) or it will be reclaimed. See [design/ws.md](../design/ws.md#abandoned-listener-reclaim-and-the-__ping__-contract).
804
+
686
805
  ### `session:clear` (C→S)
687
806
 
688
807
  Detach from the current session (for `/session new`). Server unsubscribes the event listener without deleting the session.
@@ -712,5 +831,6 @@ Hard delete (JSON + SQLite rows, cascades to descendants) goes through the REST
712
831
  | `complete` | `stopReason`, `taskId?` | Turn finished |
713
832
  | `error` | `error` | Error message |
714
833
  | `chat:queued` | `reason`, `message` | Message queued (compact/busy) |
834
+ | `listener:released` | `sessionId` | Event listener reclaimed — re-send `subscribe` (see above) |
715
835
 
716
836
  Events with `taskId` set belong to sub-agent turns (nested sessions); events without are the root agent's. Full list in [design/ws.md](../design/ws.md).
@@ -96,6 +96,54 @@ sudo systemctl daemon-reload
96
96
  sudo systemctl enable --now halo
97
97
  ```
98
98
 
99
+ ### Crash semantics — why `Restart=on-failure` is load-bearing
100
+
101
+ The server **exits 1 on any uncaught exception** (`process.on('uncaughtException')`
102
+ logs the stack, then `exit(1)` after a 100ms drain for queued stdout/WS frames).
103
+ It does not try to keep serving: past an unwound handler the process may hold
104
+ half-taken locks and half-written files, and since the db is the source of truth
105
+ — every daemon (cron / evo ticker / channels) reconciles from it on boot — a
106
+ restart is a full recovery while a zombie process silently corrupts state.
107
+
108
+ So the supervisor is what turns a crash into a ~5s blip: the unit above pairs
109
+ `Restart=on-failure` (fires exactly on non-zero exit) with `RestartSec=5`.
110
+ Docker deployments want an equivalent `restart:` policy. Under a bare `nohup
111
+ node dist/index.js` / foreground `halo server start` there is still no
112
+ supervisor — a crash is a hard stop, with the stack in
113
+ `~/.halo/global/logs/server.log` for whoever restarts it.
114
+ `unhandledRejection` is only logged, not fatal.
115
+
116
+ **`halo server start -d` brings its own bounded supervisor.** The detached
117
+ process `-d` spawns is not the server: it re-execs `halo server start`
118
+ (foreground) with `HALO_SUPERVISE=1`, and *that* process supervises the real
119
+ server as its child (`packages/cli/src/server-supervisor.ts`). Policy:
120
+
121
+ - **Restarts only on failure** — non-zero exit, or death by any signal other
122
+ than `SIGTERM` / `SIGINT`. `SIGKILL` (OOM killer) and `SIGSEGV` count as
123
+ crashes and *are* restarted; the same split systemd's `Restart=on-failure`
124
+ makes. A clean `exit(0)` is never restarted.
125
+ - **Bounded**: at most **5 restarts per 5-minute sliding window**, then it gives
126
+ up (exit 1) with a log line telling you to fix the cause and re-run `halo
127
+ server start -d`.
128
+ - **2s pause between attempts**, so an instant-crash loop can't spin the CPU.
129
+ - Every decision lands in the daemon log as `[respawn] …`, on the same fds the
130
+ server writes to (`~/.halo/global/logs/server.log`).
131
+
132
+ `halo server stop` (and `--force`) is not mistaken for a crash: it stamps the
133
+ server pid into a marker file `~/.halo/global/server.stop` *before* signalling,
134
+ and the supervisor stands down when it sees a marker naming the pid it just
135
+ lost. Signals alone couldn't carry that intent (on Windows a `SIGTERM` surfaces
136
+ as a plain `code 1`), and the marker is also what stops a respawn when `stop`
137
+ arrives while the supervisor is mid-backoff. Stamping the pid rather than a bare
138
+ flag keeps `restart` unambiguous — the new supervisor can't consume the outgoing
139
+ one's marker.
140
+
141
+ Pid ownership is unchanged: `~/.halo/global/server.lock` is still written and
142
+ flock'd by the **server** process itself, so `halo server stop|restart|status`
143
+ keep targeting the server exactly as before. The supervisor has no pidfile of
144
+ its own; killing it directly leaves the server running as an orphan (same end
145
+ state as the pre-supervisor `-d`), and `halo server stop` still stops it.
146
+
99
147
  ## 6. Verify
100
148
 
101
149
  ```bash
@@ -192,13 +192,13 @@ Still good practice: build server + admin **before** `pnpm dist:arm64`.
192
192
  explicitly. This is what carries the built-in skills (incl. `send-file`),
193
193
  agents, prompts, models into the dmg.
194
194
  3. **Native fixup** — re-fetch `better-sqlite3` prebuilds via `prebuild-install
195
- --target=22.11.0` **unconditionally** (pins the ABI to the bundled node
196
- regardless of the build host's node — see the ABI gotcha below);
195
+ --target=$NODE_VERSION_BARE` **unconditionally** (pins the ABI to the bundled
196
+ node regardless of the build host's node — see the ABI gotcha below);
197
197
  `@parcel/watcher`'s per-platform binary is swapped only when cross-staging.
198
198
  4. **Trim bloat** — drop non-target `node-pty` prebuilds, `better-sqlite3`'s
199
199
  C source, and the server's `src/`/`tests/`.
200
200
  5. **Copy `admin-out/`** — the Next.js static export.
201
- 6. **Download + stage node** — fetch `node-v22.11.0-<platform>-<arch>` from
201
+ 6. **Download + stage node** — fetch `node-$NODE_VERSION-<platform>-<arch>` from
202
202
  nodejs.org, extract the `node` binary into `resources/`.
203
203
  7. **codesign (ad-hoc)** — `codesign --force --sign -` on the node binary for
204
204
  Gatekeeper. **We deliberately do NOT `strip`** — see gotcha below.
@@ -236,7 +236,8 @@ Still good practice: build server + admin **before** `pnpm dist:arm64`.
236
236
  looks fine but won't boot. Keep the full ~114 MB binary; the ~30 MB saved
237
237
  isn't worth a dead app. (`stage-runtime.mjs` has the strip step removed.)
238
238
 
239
- - **better-sqlite3's ABI must match the BUNDLED node (22.11.0 / ABI 127), not
239
+ - **better-sqlite3's ABI must match the BUNDLED node (`NODE_VERSION` in
240
+ `stage-runtime.mjs`, currently v22.11.0 / ABI 127), not
240
241
  the build host's node.** `pnpm deploy` / `npm install` hard-link whatever
241
242
  `.node` the build host's *active* node produced. If a homebrew/nvm node 23
242
243
  (ABI 131) shadows the pinned 22 on the build machine, the staged
@@ -245,9 +246,10 @@ Still good practice: build server + admin **before** `pnpm dist:arm64`.
245
246
  (`ERR_DLOPEN_FAILED`, server `code=1`). The dmg packages fine and runs on the
246
247
  builder's own machine (whose node happens to match), so it ships broken to
247
248
  everyone else. Three guards now prevent this, all in `stage-runtime.mjs`:
248
- 1. `fetchTargetArchNatives()` runs `prebuild-install --target=22.11.0` for
249
- better-sqlite3 **unconditionally** (not just when cross-staging), so the
250
- ABI is always re-pinned to the bundled node regardless of the host node.
249
+ 1. `fetchTargetArchNatives()` runs `prebuild-install
250
+ --target=$NODE_VERSION_BARE` for better-sqlite3 **unconditionally** (not
251
+ just when cross-staging), so the ABI is always re-pinned to the bundled
252
+ node regardless of the host node.
251
253
  2. An early gate aborts if the build host's node major ≠ the bundled major
252
254
  (`nvm use 22` then rebuild; override with `HALO_SKIP_NODE_CHECK=1`).
253
255
  3. `smokeTestNativeAddons()` loads the staged better-sqlite3 with the bundled
@@ -257,6 +259,12 @@ Still good practice: build server + admin **before** `pnpm dist:arm64`.
257
259
  Verify a built dmg manually: `"<app>/Contents/Resources/node" -e
258
260
  "new (require('<app>/Contents/Resources/server-runtime/node_modules/better-sqlite3'))(':memory:')"`
259
261
  should exit 0.
262
+ **Bumping the bundled node = editing `NODE_VERSION` only.** Every effective
263
+ use (both `prebuild-install --target=` call sites, the host-major gate, the
264
+ download URL, the log lines) derives from it via `NODE_VERSION_BARE =
265
+ NODE_VERSION.slice(1)`. The version used to be re-typed as a literal
266
+ `22.11.0` in the `--target` args, so a bump silently left the ABI pinned to
267
+ the old node.
260
268
 
261
269
  - **Desktop port is sequential from 9527 (NOT random), and that's load-bearing
262
270
  for persistence.** `main.cjs` `findFreePort()` tries 9527 first, then scans
@@ -284,6 +292,32 @@ Still good practice: build server + admin **before** `pnpm dist:arm64`.
284
292
  have run the menu action that installs `/usr/local/bin/halo`; a fresh
285
293
  install that never did has no `halo` on any PATH.
286
294
 
295
+ - **The installed `halo` launcher hard-codes paths inside the .app, so moving
296
+ the app breaks it — the script probes at run time.** `/usr/local/bin/halo` is
297
+ a tiny `sh` script (not a symlink: it must pair the bundled `node` with the
298
+ `cli-runtime` entry, which one symlink can't express), so its inlined
299
+ `Contents/Resources/...` paths go stale the moment the user drags the app
300
+ Downloads → /Applications — the classic post-install move. `buildCliLauncher()`
301
+ in `main.cjs` therefore emits a script that checks `-x $NODE` / `-f $CLI`
302
+ first and, when stale, retries at the standard location
303
+ (`/Applications/<AppName>.app/Contents/Resources/{node,cli-runtime/dist/index.js}`,
304
+ derived from the *current* bundle's basename). Still missing → print
305
+ "re-run Install 'halo' Command from the app menu" and `exit 127`, instead of
306
+ exec's cryptic "no such file or directory". Dev (`!app.isPackaged`) keeps the
307
+ plain one-line exec — `nodeBin` there may be a bare PATH lookup and
308
+ `resourcesPath` points into electron/dist, so there's no .app to heal against.
309
+
310
+ - **First-run detection parses `secrets/config.yaml` with a real yaml parser.**
311
+ `configHasPassword()` used to regex `^\s*password:\s*\n\s*value:\s*"([^"]*)"`,
312
+ which only matched a *double-quoted* value. `halo setup` / change-password
313
+ write through the yaml Document API, which emits the scrypt hash unquoted (or
314
+ escape-folded across lines) depending on the prior scalar style — so a real
315
+ password regexed as "not set" and the desktop re-showed first-run setup on
316
+ every launch. It now `require()`s the `yaml` package out of the staged
317
+ server-runtime (always present — the desktop can't boot without it) and reads
318
+ `server.password.value`; the regex survives only as a `catch` fallback,
319
+ widened to accept unquoted scalars.
320
+
287
321
  - **Server child lifecycle.** On `before-quit` the main process sends SIGTERM,
288
322
  then force-SIGKILLs after 1.5 s. This is the backstop for a wedged server
289
323
  whose main thread is stuck in a syscall and never delivers SIGTERM. Without
@@ -111,6 +111,7 @@ Source: `packages/server/src/config.ts`
111
111
  | `HALO_LOG_MAX_FILES` | `5` | `config.yaml logging.max_files` | Retained rotated logs |
112
112
  | `HALO_LOG_LEVEL` | `warn` | `settings.yaml general.logging.level` | Log level: debug \| info \| warn \| error |
113
113
  | `HALO_JWT_SECRET` | (none) | `config.yaml server.jwt_secret` | JWT signing secret (generated by `halo setup` if missing; stripped from child-process env) |
114
+ | `HALO_BADGE` | (none) | — | Environment badge (e.g. `DEV`) returned by `GET /api/auth/check` on **both** the 200 and 401 branches, so the admin can brand the login screen too. The admin repaints the favicon with a badge band and prefixes the tab title, making parallel dev/prod tabs tellable apart. dev and prod serve the same static build, so this has to be a runtime signal. Unset / whitespace-only → `null` → stock branding. Because the 401 branch carries it, the value is **public to unauthenticated clients** — keep it a label (`DEV`, `staging`), never anything sensitive. |
114
115
  | `HALO_RUNTIME_MODE` | (none) | — | `agentcore` runs the server as an Amazon Bedrock AgentCore Runtime container (see [design/agentcore.md](../design/agentcore.md)) |
115
116
  | `HALO_WORKSPACE` | `process.cwd()` | — | Workspace root in agentcore mode |
116
117
 
@@ -156,6 +157,8 @@ Two ways to authenticate:
156
157
  1. **Interactive** — run `halo setup`, pick "Set / change password". The value is scrypt-hashed and written to `~/.halo/secrets/config.yaml server.password`. Best for personal installs.
157
158
  2. **Env** — export `HALO_PASSWORD=...` before launching the server. The plaintext value is compared directly (no hashing) and takes precedence over any stored hash. A stored hash isn't required at all — env plaintext alone satisfies the startup gate, so env-only deployments are legal. Best for Docker / systemd / CI where a secrets manager already protects the env.
158
159
 
160
+ Once logged in, the password can also be changed from the panel (**Settings → Security**, requires the current password) — except when `HALO_PASSWORD` env manages it, in which case the endpoint refuses (the env value would keep winning at login).
161
+
159
162
  ## Verify
160
163
 
161
164
  ```bash