@turmind/halo 1.0.3 → 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 (52) 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/442.68ed26e27eb1d84f.js +1 -0
  5. package/admin-out/_next/static/chunks/515.4d98fae302278913.js +1 -0
  6. package/admin-out/_next/static/chunks/896.ed21ec1e0846d20d.js +1 -0
  7. package/admin-out/_next/static/chunks/935.543aeec8456b6061.js +1 -0
  8. package/admin-out/_next/static/chunks/app/page-86cb00582129ae76.js +1 -0
  9. package/admin-out/_next/static/chunks/webpack-fc1574554e7b1108.js +1 -0
  10. package/admin-out/_next/static/css/0d0ca7b3c8cc5562.css +3 -0
  11. package/admin-out/index.html +1 -1
  12. package/admin-out/index.txt +5 -5
  13. package/bundled-docs/design/agent.md +11 -0
  14. package/bundled-docs/design/agentcore.md +10 -0
  15. package/bundled-docs/design/architecture.md +28 -29
  16. package/bundled-docs/design/background-dispatch.md +30 -43
  17. package/bundled-docs/design/cron.md +10 -1
  18. package/bundled-docs/design/evolution.md +18 -2
  19. package/bundled-docs/design/feishu.md +19 -6
  20. package/bundled-docs/design/goal-mode.md +2 -2
  21. package/bundled-docs/design/halo-city.md +7 -0
  22. package/bundled-docs/design/session.md +50 -2
  23. package/bundled-docs/design/slack.md +20 -6
  24. package/bundled-docs/design/storage.md +39 -2
  25. package/bundled-docs/design/telegram.md +20 -8
  26. package/bundled-docs/design/web.md +1 -0
  27. package/bundled-docs/design/wechat.md +13 -17
  28. package/bundled-docs/design/ws.md +35 -9
  29. package/bundled-docs/dev/add-channel.md +49 -37
  30. package/bundled-docs/dev/api.md +121 -35
  31. package/bundled-docs/dev/deploy.md +48 -0
  32. package/bundled-docs/dev/desktop-packaging.md +41 -7
  33. package/bundled-docs/dev/env.md +1 -0
  34. package/bundled-docs/dev/previews.md +58 -3
  35. package/bundled-docs/guide/channels/wechat.md +3 -3
  36. package/bundled-docs/guide/cli.md +24 -5
  37. package/bundled-docs/requirements/chat.md +11 -2
  38. package/bundled-docs/requirements/editor.md +5 -2
  39. package/bundled-docs/requirements/explorer.md +1 -0
  40. package/bundled-docs/requirements/session.md +4 -3
  41. package/bundled-docs/requirements/source-control.md +1 -0
  42. package/dist/index.js +14293 -13212
  43. package/package.json +4 -1
  44. package/templates/schema.sql +8 -10
  45. package/admin-out/_next/static/chunks/212-c5f046048be9c367.js +0 -1
  46. package/admin-out/_next/static/chunks/220.3dc41cd6269b88e6.js +0 -1
  47. package/admin-out/_next/static/chunks/442.f4302087bddb1ec1.js +0 -1
  48. package/admin-out/_next/static/chunks/app/page-5a6cf2b73a12b849.js +0 -1
  49. package/admin-out/_next/static/chunks/webpack-47520cdc55be63e7.js +0 -1
  50. package/admin-out/_next/static/css/62b8bf49e6404ffc.css +0 -3
  51. /package/admin-out/_next/static/{CWrUwTypkebkhwZ5i82_G → lOrGs0dR-jmXz9PgabRBc}/_buildManifest.js +0 -0
  52. /package/admin-out/_next/static/{CWrUwTypkebkhwZ5i82_G → lOrGs0dR-jmXz9PgabRBc}/_ssgManifest.js +0 -0
@@ -18,7 +18,7 @@ File: `packages/server/src/middleware/auth.ts`
18
18
  |---|---|---|
19
19
  | POST | `/api/auth/login` | Password login, returns a JWT cookie |
20
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 |
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
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 |
23
23
 
24
24
  ## Files
@@ -31,7 +31,6 @@ File: `packages/server/src/routes/files.ts`
31
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). |
32
32
  | GET | `/api/files?path=&projectId=` | Read file content (max 10 MB) |
33
33
  | GET | `/api/files/stat?path=&projectId=` | Lightweight mtime + size |
34
- | GET | `/api/files/diff?path=&projectId=` | Git diff |
35
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 |
36
35
  | PUT | `/api/files` | Save file (body: `{path, content, projectId}`) |
37
36
  | POST | `/api/files/new` | Create empty file |
@@ -40,7 +39,9 @@ File: `packages/server/src/routes/files.ts`
40
39
  | POST | `/api/files/upload` | Multipart upload |
41
40
  | DELETE | `/api/files?path=&projectId=` | Delete file or directory (recursive) |
42
41
 
43
- 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.
44
45
 
45
46
  ### Filesystem browse (not project-scoped — for the workspace picker)
46
47
 
@@ -49,9 +50,29 @@ All file operations validate the path stays inside the project root (prevents pa
49
50
  | GET | `/api/fs/home` | Returns server `homedir()` — fallback when the frontend has no `?folder=` URL param. Returns `{ home }`. |
50
51
  | GET | `/api/fs/exists?path=/abs` | Validates an absolute path exists and whether it's a directory. Returns `{exists, isDirectory?}`. |
51
52
  | GET | `/api/fs/browse?path=/abs` | Lists immediate directory children (hidden ones dropped). Returns `{path, parent, entries: [{name, path}]}`. |
52
- | 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 }` |
53
69
 
54
- Absolute paths only. Purpose: Explorer's workspace picker and switching validation. Reading/writing files still goes through `/api/files/*` and remains project-sandboxed.
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).
71
+
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.
55
76
 
56
77
  ## Session Logs (unified)
57
78
 
@@ -62,34 +83,42 @@ Unified session log API — list + read session files across all agents.
62
83
  | Method | Path | Purpose |
63
84
  |---|---|---|
64
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) |
65
- | GET | `/api/sessions/logs/:id?projectId=` | Full session log (scans across agent dirs) |
66
- | DELETE | `/api/sessions/logs/:id?projectId=` | Delete the session log |
67
- | 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 |
68
90
  | GET | `/api/sessions/goal?projectId=` | Latest goal binding for the workspace (goal-mode banner / input-lock seed) — see [detail](#get-apisessionsgoalprojectidabs) |
69
91
 
70
- 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.
71
93
 
72
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.
73
95
 
74
- ### Legacy DB sessions
96
+ ## Channel accounts — shared field validation
75
97
 
76
- | 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 |
77
105
  |---|---|---|
78
- | GET | `/api/sessions?projectId=` | List sessions (DB, legacy) |
79
- | GET | `/api/sessions/:id` | Full session (DB, legacy) |
80
- | 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.
81
110
 
82
- ## Weixin Channel
111
+ ## WeChat Channel
83
112
 
84
- File: `packages/server/src/routes/weixin.ts`
113
+ File: `packages/server/src/routes/wechat.ts`
85
114
 
86
115
  | Method | Path | Purpose |
87
116
  |---|---|---|
88
- | POST | `/api/weixin/login/start` | Start the QR login flow, returns `{qrcodeUrl, sessionKey}` |
89
- | POST | `/api/weixin/login/wait` | Poll QR status. body: `{sessionKey, workspacePath, label?, language?}`; on success the account is inserted and long-polling starts |
90
- | GET | `/api/weixin/accounts` | List every bot account |
91
- | PATCH | `/api/weixin/accounts/:id` | Change label / workspacePath / enabled / accessLevel / language |
92
- | 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 |
93
122
 
94
123
  See [design/wechat.md](../design/wechat.md).
95
124
 
@@ -104,7 +133,7 @@ File: `packages/server/src/routes/telegram.ts`. Admin cookie auth. Mounted under
104
133
  | PATCH | `/api/telegram/accounts/:id` | Update `label` / `workspacePath` / `enabled` / `accessLevel` / `allowedUsers` / `language`. Stops then restarts the bot if still enabled |
105
134
  | DELETE | `/api/telegram/accounts/:id` | Stop long-poll and delete the row |
106
135
 
107
- `workspacePath` must be absolute and exist; `ensureWorkspaceHalo()` runs on POST/PATCH.
136
+ `workspacePath` / `accessLevel` follow the [shared validation rules](#channel-accounts--shared-field-validation).
108
137
 
109
138
  ## Slack Channel
110
139
 
@@ -140,7 +169,7 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
140
169
  |---|---|---|
141
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) |
142
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}` |
143
- | 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}` |
144
173
  | DELETE | `/api/cron/jobs/:id` | Unschedule, delete every `cron_runs` row for the job, then delete the job. Returns `{ok}` |
145
174
  | POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget). Returns `{ok}` |
146
175
  | POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
@@ -163,7 +192,7 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
163
192
 
164
193
  ## Source Control (Git)
165
194
 
166
- 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.
167
196
 
168
197
  ### Repo state (read)
169
198
 
@@ -178,6 +207,8 @@ File: `packages/server/src/routes/git.ts`. Admin cookie auth. Backs the Source C
178
207
 
179
208
  ### Mutations (write — each broadcasts `file:changed`)
180
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
+
181
212
  | Method | Path | Purpose |
182
213
  |---|---|---|
183
214
  | POST | `/api/git/stage` | Body `{projectId, paths: string[]}`. `git add`. 400 if `paths` empty |
@@ -218,6 +249,21 @@ File: `packages/server/src/routes/web.ts`
218
249
 
219
250
  ### Public endpoints (token auth via `x-token` header or `?token=` query)
220
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
+
221
267
  | Method | Path | Purpose |
222
268
  |---|---|---|
223
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`) |
@@ -230,8 +276,9 @@ See [design/web.md](../design/web.md).
230
276
 
231
277
  ## Show (world snapshot)
232
278
 
233
- File: `packages/server/src/routes/show.ts`. Token auth (same `x-token` as the
234
- 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
235
282
  snapshot powering the `halo-city` pixel visualizer — one call returns the whole
236
283
  runtime so the frontend can render rooms (workspaces) + characters (sessions).
237
284
 
@@ -242,7 +289,7 @@ runtime so the frontend can render rooms (workspaces) + characters (sessions).
242
289
 
243
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.
244
291
 
245
- 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.
246
293
 
247
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).
248
295
 
@@ -269,6 +316,8 @@ File: `packages/server/src/routes/agent-configs.ts`
269
316
  | DELETE | `/api/agent-configs/:id/sessions/:sessionId` | Delete a session |
270
317
  | DELETE | `/api/agent-configs/:id/sessions?all=1` | Delete every session for the agent |
271
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
+
272
321
  ## Skills
273
322
 
274
323
  File: `packages/server/src/routes/skills.ts`
@@ -301,6 +350,8 @@ File: `packages/server/src/routes/settings.ts`
301
350
  | PATCH | `/api/settings` | Update a single key |
302
351
  | DELETE | `/api/settings` | Delete a key |
303
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
+
304
355
  ## Metrics
305
356
 
306
357
  File: `packages/server/src/routes/metrics.ts`.
@@ -309,7 +360,7 @@ File: `packages/server/src/routes/metrics.ts`.
309
360
  |---|---|---|
310
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` |
311
362
 
312
- 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.
313
364
 
314
365
  ## Self-Evolution
315
366
 
@@ -355,12 +406,18 @@ Source: [packages/server/src/middleware/auth.ts:123-134](../../../packages/serve
355
406
 
356
407
  ```json
357
408
  // 200 authenticated
358
- { "authenticated": true }
409
+ { "authenticated": true, "badge": "DEV" }
359
410
 
360
411
  // 401 not authenticated
361
- { "authenticated": false }
412
+ { "authenticated": false, "badge": "DEV" }
362
413
  ```
363
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
+
364
421
  ### POST `/api/auth/change-password`
365
422
 
366
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.
@@ -517,7 +574,7 @@ Two shapes, selected by `rootOnly`:
517
574
  "source": "explorer",
518
575
  "createdAt": "2026-04-30T08:00:00Z",
519
576
  "updatedAt": "2026-04-30T08:05:00Z",
520
- "messageCount": 12,
577
+ "exchangeCount": 12,
521
578
  "parentSessionId": null,
522
579
  "contextTokens": 5975,
523
580
  "totalOutputTokens": 6058,
@@ -531,9 +588,11 @@ Two shapes, selected by `rootOnly`:
531
588
 
532
589
  Archived sessions are excluded by default; pass `?includeArchived=1` to include them.
533
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
+
534
593
  ### GET `/api/sessions/logs/:id?projectId=<abs>`
535
594
 
536
- 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.
537
596
 
538
597
  ```json
539
598
  // 200 — same shape as the on-disk JSON
@@ -548,11 +607,38 @@ Returns the full session file. If only `rawMessages` is present (sub-agent sessi
548
607
  "messageCount": 12,
549
608
  "contextTokens": 5975,
550
609
  "totalOutputTokens": 6058,
610
+ "archiveCount": 2,
551
611
  "messages": [ /* SessionMessage[] — see design/storage.md */ ],
552
612
  "rawMessages": [ /* optional */ ]
553
613
  }
554
614
  ```
555
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
+
556
642
  ### GET `/api/sessions/goal?projectId=<abs>`
557
643
 
558
644
  Source: `packages/server/src/routes/sessions.ts` (`findLatestGoal` in `agents/goal-mode.ts`)
@@ -629,9 +715,9 @@ Resolves declared schema (from `models/<id>.yaml` `secrets:` and `skills/<id>/co
629
715
 
630
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.
631
717
 
632
- ### POST `/api/weixin/login/start`
718
+ ### POST `/api/wechat/login/start`
633
719
 
634
- 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)
635
721
 
636
722
  ```json
637
723
  // Request
@@ -641,7 +727,7 @@ Source: [packages/server/src/routes/weixin.ts:42-46](../../../packages/server/sr
641
727
  { "qrcodeUrl": "https://...", "sessionKey": "abc123" }
642
728
  ```
643
729
 
644
- ### POST `/api/weixin/login/wait`
730
+ ### POST `/api/wechat/login/wait`
645
731
 
646
732
  ```json
647
733
  // Request
@@ -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
 
@@ -15,6 +15,10 @@ previews/
15
15
  ├── ui/
16
16
  │ ├── preview-shell.tsx Standard header (filename + Open-as-Text + Download + extraToolbar)
17
17
  │ ├── use-preview-fetch.ts Hook: fetch + AbortController + parse, returns {data, error, loading}
18
+ │ ├── use-data-fetch.ts JSON-endpoint sibling: abortable fetcher → {data, error, loading},
19
+ │ │ for previews that parse server-side instead of raw bytes
20
+ │ ├── data-table.tsx Shared table renderer (headers + rows + prev/next pager) for
21
+ │ │ server-parsed previews (parquet / sqlite / csv)
18
22
  │ └── print.ts Pop-up print helper
19
23
  ├── workers/ Parse workers (one per heavy format)
20
24
  │ ├── worker-client.ts Generic WorkerClient<T> class — id-routed postMessage
@@ -30,7 +34,10 @@ previews/
30
34
  ├── pptx-notes.ts pptx zip helpers: speaker-notes extraction (jszip +
31
35
  │ DOMParser, play order via sldIdLst) + Content_Types
32
36
  │ repair for decks with dangling Override parts
33
- └── media.tsx / media-view.tsx
37
+ ├── media.tsx / media-view.tsx
38
+ ├── parquet.tsx / parquet-view.tsx Server-parsed: GET /api/data-preview/parquet
39
+ ├── sqlite.tsx / sqlite-view.tsx Server-parsed: GET /api/data-preview/sqlite/*
40
+ └── csv.tsx / csv-view.tsx Server-parsed: GET /api/data-preview/csv (csv + tsv)
34
41
  ```
35
42
 
36
43
  **Two-file-per-plugin pattern**: `foo.tsx` is tiny metadata (no runtime deps). `foo-view.tsx` holds the component and its heavy dependencies. The metadata file uses `React.lazy()` so the view file (and its deps) only loads when a user actually opens that file type.
@@ -40,7 +47,7 @@ previews/
40
47
  ```typescript
41
48
  interface PreviewPlugin {
42
49
  id: string // stable id, e.g. 'pdf'
43
- extensions: readonly string[] // lowercase, no dot — e.g. ['xlsx', 'xls', 'csv']
50
+ extensions: readonly string[] // lowercase, no dot — e.g. ['xlsx', 'xls'] (csv/tsv is a separate plugin)
44
51
  Component: React.ComponentType<PreviewProps>
45
52
  heavy?: boolean // true = main-thread-heavy; active-only mount, skip MRU cache
46
53
  }
@@ -48,6 +55,8 @@ interface PreviewPlugin {
48
55
  interface PreviewProps {
49
56
  name: string // full filename including extension
50
57
  path: string // relative workspace path (or absolute for /tmp files)
58
+ projectId?: string // workspace absolute path — needed by server-parsed plugins
59
+ // (parquet/sqlite/csv) to call /api/data-preview/*
51
60
  viewUrl: string // for inline viewing, supports HTTP Range
52
61
  downloadUrl: string // for forced download (used by the shell's Download button)
53
62
  onOpenAsText?: () => void // set when the file can also be force-opened as text
@@ -206,13 +215,59 @@ Only pptx currently uses this. Don't set it by default — the MRU cache gives m
206
215
 
207
216
  When the user right-clicks a previewable file in Explorer and picks "Open as Text", Canvas closes the preview tab and opens the raw file in Monaco. The preview plugin receives this as `props.onOpenAsText` — pass it straight through to `PreviewShell` and the button appears automatically.
208
217
 
218
+ ## Two plugin patterns: raw-bytes vs. server-parsed
219
+
220
+ Most built-in plugins (pdf, docx, xlsx, pptx, media) are **raw-bytes**: `usePreviewFetch` downloads the whole file as an ArrayBuffer via `GET /api/files/download?inline=1`, then parsing happens client-side (a Worker for the heavy formats). This is the pattern documented above.
221
+
222
+ Parquet, SQLite and CSV/TSV are **server-parsed** instead: the server does the parsing and hands back one page of JSON rows at a time via `GET /api/data-preview/*` (route details in [`dev/api.md`](api.md#data-preview)), so a multi-GB file never has to reach the browser.
223
+
224
+ ```tsx
225
+ // plugins/foo-view.tsx (server-parsed)
226
+ import { useState } from 'react'
227
+ import type { PreviewProps } from '../types'
228
+ import { PreviewShell } from '../ui/preview-shell'
229
+ import { useDataFetch } from '../ui/use-data-fetch'
230
+ import { DataTable } from '../ui/data-table'
231
+ import { api } from '@/shared/api-client'
232
+
233
+ const PAGE_SIZE = 100
234
+
235
+ export function FooPreview({ name, path, projectId, downloadUrl, onOpenAsText }: PreviewProps) {
236
+ const [offset, setOffset] = useState(0)
237
+ const { data, error, loading } = useDataFetch(
238
+ projectId ? (signal) => api.dataPreview.foo(path, projectId, offset, PAGE_SIZE, signal) : null,
239
+ [path, projectId, offset],
240
+ )
241
+ return (
242
+ <PreviewShell name={name} downloadUrl={downloadUrl} onOpenAsText={onOpenAsText} loading={loading} error={error}>
243
+ {data && (
244
+ <DataTable
245
+ columns={data.columns}
246
+ rows={data.rows}
247
+ offset={data.offset}
248
+ limit={data.limit}
249
+ totalRows={data.totalRows}
250
+ onPage={setOffset}
251
+ />
252
+ )}
253
+ </PreviewShell>
254
+ )
255
+ }
256
+ ```
257
+
258
+ Server-parsed plugins need `PreviewProps.projectId` (the workspace absolute path, threaded from editor-panel's two `FilePreview` call sites) to build the `/api/data-preview/*` query.
259
+
260
+ **Which pattern to pick for a new format**: if the format can be read incrementally on disk (row groups, pages, or lines — like parquet/sqlite/csv), add a route in `data-preview.ts` and go server-parsed — it scales to large files and keeps the browser light. If the format can only be parsed after reading the whole file (zip-based containers like xlsx/docx/pptx), stay raw-bytes; page the fully-parsed result client-side (see xlsx's `DataTable` usage) instead of a hard row cutoff.
261
+
209
262
  ## Server side
210
263
 
211
- All previews fetch via `GET /api/files/download?inline=1` which:
264
+ Raw-bytes previews fetch via `GET /api/files/download?inline=1` which:
212
265
  - Streams the file (doesn't read entire buffer into memory)
213
266
  - Supports HTTP `Range` (206 Partial Content) for video/audio seek + progressive load
214
267
  - Aborts the read when the client disconnects (tab closed / URL changed)
215
268
 
269
+ Server-parsed previews fetch via `GET /api/data-preview/*` (schema + one page of rows, `projectId`-scoped like `/api/files/*`).
270
+
216
271
  See [`dev/api.md`](api.md) for the route details.
217
272
 
218
273
  ## Testing a new plugin
@@ -53,13 +53,13 @@ The QR is valid for ~3 minutes; if it expires the admin UI auto-refreshes. If yo
53
53
 
54
54
  The bot's name in your WeChat contacts is whatever the iLink platform set it to (typically a generic placeholder like `小助手`; you can rename it locally). Open the bot's chat in WeChat and send `hello` → expect a streamed reply.
55
55
 
56
- If nothing happens, check halo server logs for `[weixin]` lines.
56
+ If nothing happens, check halo server logs for `[wechat]` lines.
57
57
 
58
58
  ## How halo handles inbound
59
59
 
60
60
  - **Text** — sent to the agent
61
61
  - **Images** — passed to the LLM as multimodal content
62
- - **Voice** — decrypted, saved under `<workspace>/.halo/assets/weixin/inbound/<accountId>/<date>/`, path included in the agent's input
62
+ - **Voice** — decrypted, saved under `<workspace>/.halo/assets/weixin/inbound/<accountId>/<date>/` (the `weixin` directory name is kept for backward compatibility with existing workspaces), path included in the agent's input
63
63
  - **Video / files** — same as voice
64
64
  - **Group chats** — not supported in v1 (group messages are dropped)
65
65
  - **Self-loop** — bot-authored messages are filtered out
@@ -106,5 +106,5 @@ Each scan = one account = one workspace binding. To use a second workspace, open
106
106
 
107
107
  - Code: `packages/server/src/channels/wechat/`
108
108
  - Login flow: `packages/server/src/channels/wechat/login.ts`
109
- - Admin UI: `packages/admin/src/features/weixin/weixin-settings.tsx`
109
+ - Admin UI: `packages/admin/src/features/wechat/wechat-settings.tsx`
110
110
  - Design notes: [../../design/wechat.md](../../design/wechat.md)