@turmind/halo 1.0.3 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/212-8ac9dfe9c24e512c.js +1 -0
- package/admin-out/_next/static/chunks/220.e5a2532bda77e816.js +1 -0
- package/admin-out/_next/static/chunks/438-866a1a20e9efa78a.js +1 -0
- package/admin-out/_next/static/chunks/442.68ed26e27eb1d84f.js +1 -0
- package/admin-out/_next/static/chunks/515.4d98fae302278913.js +1 -0
- package/admin-out/_next/static/chunks/635-d7fd95bff8674116.js +1 -0
- package/admin-out/_next/static/chunks/896.ed21ec1e0846d20d.js +1 -0
- package/admin-out/_next/static/chunks/935.543aeec8456b6061.js +1 -0
- package/admin-out/_next/static/chunks/app/page-686cd94fdf9df63b.js +1 -0
- package/admin-out/_next/static/chunks/webpack-fc1574554e7b1108.js +1 -0
- package/admin-out/_next/static/css/0d0ca7b3c8cc5562.css +3 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/agent.md +11 -0
- package/bundled-docs/design/agentcore.md +10 -0
- package/bundled-docs/design/architecture.md +28 -29
- package/bundled-docs/design/background-dispatch.md +30 -43
- package/bundled-docs/design/cron.md +10 -1
- package/bundled-docs/design/evolution.md +18 -2
- package/bundled-docs/design/feishu.md +19 -6
- package/bundled-docs/design/goal-mode.md +2 -2
- package/bundled-docs/design/halo-city.md +7 -0
- package/bundled-docs/design/session.md +62 -2
- package/bundled-docs/design/slack.md +20 -6
- package/bundled-docs/design/storage.md +39 -2
- package/bundled-docs/design/telegram.md +20 -8
- package/bundled-docs/design/web.md +1 -0
- package/bundled-docs/design/wechat.md +13 -17
- package/bundled-docs/design/ws.md +35 -9
- package/bundled-docs/dev/add-channel.md +49 -37
- package/bundled-docs/dev/api.md +123 -35
- package/bundled-docs/dev/deploy.md +48 -0
- package/bundled-docs/dev/desktop-packaging.md +41 -7
- package/bundled-docs/dev/env.md +1 -0
- package/bundled-docs/dev/previews.md +58 -3
- package/bundled-docs/guide/channels/wechat.md +3 -3
- package/bundled-docs/guide/cli.md +24 -5
- package/bundled-docs/requirements/chat.md +13 -2
- package/bundled-docs/requirements/editor.md +5 -2
- package/bundled-docs/requirements/explorer.md +1 -0
- package/bundled-docs/requirements/session.md +5 -3
- package/bundled-docs/requirements/source-control.md +1 -0
- package/dist/index.js +14342 -13200
- package/package.json +4 -1
- package/templates/schema.sql +8 -10
- package/admin-out/_next/static/chunks/212-c5f046048be9c367.js +0 -1
- package/admin-out/_next/static/chunks/220.3dc41cd6269b88e6.js +0 -1
- package/admin-out/_next/static/chunks/4-e7ba0b1ced30190b.js +0 -1
- package/admin-out/_next/static/chunks/442.f4302087bddb1ec1.js +0 -1
- package/admin-out/_next/static/chunks/635-3dd0043ed0520b9d.js +0 -1
- package/admin-out/_next/static/chunks/app/page-5a6cf2b73a12b849.js +0 -1
- package/admin-out/_next/static/chunks/webpack-47520cdc55be63e7.js +0 -1
- package/admin-out/_next/static/css/62b8bf49e6404ffc.css +0 -3
- /package/admin-out/_next/static/{CWrUwTypkebkhwZ5i82_G → Qre3-OObLUgfkv8BEU08q}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{CWrUwTypkebkhwZ5i82_G → Qre3-OObLUgfkv8BEU08q}/_ssgManifest.js +0 -0
package/bundled-docs/dev/api.md
CHANGED
|
@@ -4,6 +4,8 @@ All REST endpoints are served by Hono on port 9527 at `/api/`.
|
|
|
4
4
|
|
|
5
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
|
+
Compression: every response negotiates gzip/deflate via `hono/compress` (`threshold: 1024` — bodies under 1 KiB pass through uncompressed), mounted after CORS so it covers both `/api/*` JSON and the served admin static assets. `text/event-stream` (SSE) is excluded, so `/api/web/chat`'s streamed reply is unaffected; WS upgrades never enter the Hono fetch pipeline either.
|
|
8
|
+
|
|
7
9
|
## Health
|
|
8
10
|
|
|
9
11
|
| Method | Path | Purpose |
|
|
@@ -18,7 +20,7 @@ File: `packages/server/src/middleware/auth.ts`
|
|
|
18
20
|
|---|---|---|
|
|
19
21
|
| POST | `/api/auth/login` | Password login, returns a JWT cookie |
|
|
20
22
|
| 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 |
|
|
23
|
+
| 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
24
|
| 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
25
|
|
|
24
26
|
## Files
|
|
@@ -31,7 +33,6 @@ File: `packages/server/src/routes/files.ts`
|
|
|
31
33
|
| 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
34
|
| GET | `/api/files?path=&projectId=` | Read file content (max 10 MB) |
|
|
33
35
|
| GET | `/api/files/stat?path=&projectId=` | Lightweight mtime + size |
|
|
34
|
-
| GET | `/api/files/diff?path=&projectId=` | Git diff |
|
|
35
36
|
| 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
37
|
| PUT | `/api/files` | Save file (body: `{path, content, projectId}`) |
|
|
37
38
|
| POST | `/api/files/new` | Create empty file |
|
|
@@ -40,7 +41,9 @@ File: `packages/server/src/routes/files.ts`
|
|
|
40
41
|
| POST | `/api/files/upload` | Multipart upload |
|
|
41
42
|
| DELETE | `/api/files?path=&projectId=` | Delete file or directory (recursive) |
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
**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.
|
|
45
|
+
|
|
46
|
+
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
47
|
|
|
45
48
|
### Filesystem browse (not project-scoped — for the workspace picker)
|
|
46
49
|
|
|
@@ -49,9 +52,29 @@ All file operations validate the path stays inside the project root (prevents pa
|
|
|
49
52
|
| GET | `/api/fs/home` | Returns server `homedir()` — fallback when the frontend has no `?folder=` URL param. Returns `{ home }`. |
|
|
50
53
|
| GET | `/api/fs/exists?path=/abs` | Validates an absolute path exists and whether it's a directory. Returns `{exists, isDirectory?}`. |
|
|
51
54
|
| 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. |
|
|
55
|
+
| 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. |
|
|
56
|
+
|
|
57
|
+
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.
|
|
58
|
+
|
|
59
|
+
## Data Preview
|
|
60
|
+
|
|
61
|
+
File: `packages/server/src/routes/data-preview.ts`
|
|
62
|
+
|
|
63
|
+
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.
|
|
64
|
+
|
|
65
|
+
| Method | Path | Purpose |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| GET | `/api/data-preview/sqlite/tables?path=&projectId=` | List tables. Returns `{ tables: [{name, rowCount}] }` |
|
|
68
|
+
| GET | `/api/data-preview/sqlite/rows?path=&projectId=&table=&offset=&limit=` | One page of rows. Returns `{ table, columns, rows, totalRows, offset, limit }` |
|
|
69
|
+
| GET | `/api/data-preview/parquet?path=&projectId=&offset=&limit=` | Schema + one page of rows. Returns `{ columns, rows, totalRows, offset, limit }` |
|
|
70
|
+
| GET | `/api/data-preview/csv?path=&projectId=&offset=&limit=` | Header + one page of data rows. Returns `{ columns, rows, totalRows, hasMore, offset, limit }` |
|
|
71
|
+
|
|
72
|
+
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).
|
|
53
73
|
|
|
54
|
-
|
|
74
|
+
- **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.
|
|
75
|
+
- **parquet** — reads only the footer + the row groups covering the requested page (hyparquet), never the whole file.
|
|
76
|
+
- **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).
|
|
77
|
+
- 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
78
|
|
|
56
79
|
## Session Logs (unified)
|
|
57
80
|
|
|
@@ -62,34 +85,42 @@ Unified session log API — list + read session files across all agents.
|
|
|
62
85
|
| Method | Path | Purpose |
|
|
63
86
|
|---|---|---|
|
|
64
87
|
| 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
|
-
|
|
|
67
|
-
|
|
|
88
|
+
| GET | `/api/sessions/logs/:id?projectId=` | Full session log (scans across agent dirs) — the **active** file only; archived history is a separate call |
|
|
89
|
+
| GET | `/api/sessions/logs/:id/archive/:n?projectId=` | One archived UI-log segment — see [detail](#get-apisessionslogsidarchivenprojectidabs) |
|
|
90
|
+
| DELETE | `/api/sessions/logs/:id?projectId=` | Delete the session log (and all of its archive segments) |
|
|
91
|
+
| 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
92
|
| GET | `/api/sessions/goal?projectId=` | Latest goal binding for the workspace (goal-mode banner / input-lock seed) — see [detail](#get-apisessionsgoalprojectidabs) |
|
|
69
93
|
|
|
70
|
-
The list endpoint returns flat metadata (id / agentId / agentName / title / timestamps /
|
|
94
|
+
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
95
|
|
|
72
96
|
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
97
|
|
|
74
|
-
|
|
98
|
+
## Channel accounts — shared field validation
|
|
75
99
|
|
|
76
|
-
|
|
100
|
+
`workspacePath` and `accessLevel` are validated at the REST boundary on both
|
|
101
|
+
POST and PATCH across all five channels (web / wechat / telegram / slack /
|
|
102
|
+
feishu) — one shared pair of checks in `channels/shared/accounts.ts`, so the
|
|
103
|
+
five can't drift (they did: four PATCHes never checked `isAbsolute`, and only
|
|
104
|
+
wechat's PATCH rejected an unknown `accessLevel`):
|
|
105
|
+
|
|
106
|
+
| Field | Rule | 400 body |
|
|
77
107
|
|---|---|---|
|
|
78
|
-
|
|
|
79
|
-
|
|
|
80
|
-
| DELETE | `/api/sessions/:id` | Delete session (DB, legacy) |
|
|
108
|
+
| `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: …"}` |
|
|
109
|
+
| `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"}` |
|
|
81
110
|
|
|
82
|
-
|
|
111
|
+
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.
|
|
83
112
|
|
|
84
|
-
|
|
113
|
+
## WeChat Channel
|
|
114
|
+
|
|
115
|
+
File: `packages/server/src/routes/wechat.ts`
|
|
85
116
|
|
|
86
117
|
| Method | Path | Purpose |
|
|
87
118
|
|---|---|---|
|
|
88
|
-
| POST | `/api/
|
|
89
|
-
| POST | `/api/
|
|
90
|
-
| GET | `/api/
|
|
91
|
-
| PATCH | `/api/
|
|
92
|
-
| DELETE | `/api/
|
|
119
|
+
| POST | `/api/wechat/login/start` | Start the QR login flow, returns `{qrcodeUrl, sessionKey}` |
|
|
120
|
+
| 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 |
|
|
121
|
+
| GET | `/api/wechat/accounts` | List every bot account |
|
|
122
|
+
| PATCH | `/api/wechat/accounts/:id` | Change label / workspacePath / enabled / accessLevel / language |
|
|
123
|
+
| DELETE | `/api/wechat/accounts/:id` | Stop long-polling and delete the DB row |
|
|
93
124
|
|
|
94
125
|
See [design/wechat.md](../design/wechat.md).
|
|
95
126
|
|
|
@@ -104,7 +135,7 @@ File: `packages/server/src/routes/telegram.ts`. Admin cookie auth. Mounted under
|
|
|
104
135
|
| PATCH | `/api/telegram/accounts/:id` | Update `label` / `workspacePath` / `enabled` / `accessLevel` / `allowedUsers` / `language`. Stops then restarts the bot if still enabled |
|
|
105
136
|
| DELETE | `/api/telegram/accounts/:id` | Stop long-poll and delete the row |
|
|
106
137
|
|
|
107
|
-
`workspacePath`
|
|
138
|
+
`workspacePath` / `accessLevel` follow the [shared validation rules](#channel-accounts--shared-field-validation).
|
|
108
139
|
|
|
109
140
|
## Slack Channel
|
|
110
141
|
|
|
@@ -140,7 +171,7 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
|
|
|
140
171
|
|---|---|---|
|
|
141
172
|
| 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
173
|
| 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`;
|
|
174
|
+
| 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
175
|
| DELETE | `/api/cron/jobs/:id` | Unschedule, delete every `cron_runs` row for the job, then delete the job. Returns `{ok}` |
|
|
145
176
|
| POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget). Returns `{ok}` |
|
|
146
177
|
| POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
|
|
@@ -163,7 +194,7 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
|
|
|
163
194
|
|
|
164
195
|
## Source Control (Git)
|
|
165
196
|
|
|
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.
|
|
197
|
+
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
198
|
|
|
168
199
|
### Repo state (read)
|
|
169
200
|
|
|
@@ -178,6 +209,8 @@ File: `packages/server/src/routes/git.ts`. Admin cookie auth. Backs the Source C
|
|
|
178
209
|
|
|
179
210
|
### Mutations (write — each broadcasts `file:changed`)
|
|
180
211
|
|
|
212
|
+
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.
|
|
213
|
+
|
|
181
214
|
| Method | Path | Purpose |
|
|
182
215
|
|---|---|---|
|
|
183
216
|
| POST | `/api/git/stage` | Body `{projectId, paths: string[]}`. `git add`. 400 if `paths` empty |
|
|
@@ -218,6 +251,21 @@ File: `packages/server/src/routes/web.ts`
|
|
|
218
251
|
|
|
219
252
|
### Public endpoints (token auth via `x-token` header or `?token=` query)
|
|
220
253
|
|
|
254
|
+
Token auth for the whole public surface — these routes, halo-city's
|
|
255
|
+
`/api/show/*`, and `/api/metrics` — runs through one shared core,
|
|
256
|
+
`resolveTokenAuth` in `middleware/web-token.ts`: header/query token parsing,
|
|
257
|
+
account lookup (must exist **and** be enabled), and the per-IP brute-force
|
|
258
|
+
bookkeeping in one place (5 strikes / 15 min lockout, one `web-token` bucket
|
|
259
|
+
shared by the three surfaces, independent of admin-login lockouts). A missing
|
|
260
|
+
token is a plain 401 and **not** a strike; only an invalid one counts.
|
|
261
|
+
|
|
262
|
+
Statuses are identical everywhere (`locked_out` 429, `missing_token` /
|
|
263
|
+
`invalid_token` 401) — only the **body shape** differs, which is why each surface
|
|
264
|
+
maps the failure itself: web + `/api/show/*` share the JSON renderer
|
|
265
|
+
(`{error: "token required" | "invalid token" | "too many failed attempts, try again later"}`),
|
|
266
|
+
`/api/metrics` answers in Prometheus comment lines (`# …`) so a scraper never
|
|
267
|
+
gets JSON, and additionally gates on `accessLevel`.
|
|
268
|
+
|
|
221
269
|
| Method | Path | Purpose |
|
|
222
270
|
|---|---|---|
|
|
223
271
|
| POST | `/api/web/chat` | Send message, receive SSE stream. Body: `{message, images?, workspace?, sessionId?, agentId?}` (overrides also accepted as `?workspace=`/`?sessionId=` query or `x-workspace`/`x-session-id` headers; `workspace` only honored when token has `accessLevel: full`) |
|
|
@@ -230,8 +278,9 @@ See [design/web.md](../design/web.md).
|
|
|
230
278
|
|
|
231
279
|
## Show (world snapshot)
|
|
232
280
|
|
|
233
|
-
File: `packages/server/src/routes/
|
|
234
|
-
Web channel
|
|
281
|
+
File: `packages/server/src/routes/halo-city.ts`. Token auth (same `x-token` as
|
|
282
|
+
the Web channel — same shared `resolveTokenAuth` core, same JSON error shape,
|
|
283
|
+
same brute-force lockout bucket). Read-only, cross-workspace
|
|
235
284
|
snapshot powering the `halo-city` pixel visualizer — one call returns the whole
|
|
236
285
|
runtime so the frontend can render rooms (workspaces) + characters (sessions).
|
|
237
286
|
|
|
@@ -242,7 +291,7 @@ runtime so the frontend can render rooms (workspaces) + characters (sessions).
|
|
|
242
291
|
|
|
243
292
|
`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
293
|
|
|
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.
|
|
294
|
+
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
295
|
|
|
247
296
|
**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
297
|
|
|
@@ -269,6 +318,8 @@ File: `packages/server/src/routes/agent-configs.ts`
|
|
|
269
318
|
| DELETE | `/api/agent-configs/:id/sessions/:sessionId` | Delete a session |
|
|
270
319
|
| DELETE | `/api/agent-configs/:id/sessions?all=1` | Delete every session for the agent |
|
|
271
320
|
|
|
321
|
+
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.
|
|
322
|
+
|
|
272
323
|
## Skills
|
|
273
324
|
|
|
274
325
|
File: `packages/server/src/routes/skills.ts`
|
|
@@ -301,6 +352,8 @@ File: `packages/server/src/routes/settings.ts`
|
|
|
301
352
|
| PATCH | `/api/settings` | Update a single key |
|
|
302
353
|
| DELETE | `/api/settings` | Delete a key |
|
|
303
354
|
|
|
355
|
+
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.
|
|
356
|
+
|
|
304
357
|
## Metrics
|
|
305
358
|
|
|
306
359
|
File: `packages/server/src/routes/metrics.ts`.
|
|
@@ -309,7 +362,7 @@ File: `packages/server/src/routes/metrics.ts`.
|
|
|
309
362
|
|---|---|---|
|
|
310
363
|
| 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
364
|
|
|
312
|
-
In `PUBLIC_PATHS` (no admin cookie). Auth is a web-channel `x-token` (header or `?token=`)
|
|
365
|
+
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
366
|
|
|
314
367
|
## Self-Evolution
|
|
315
368
|
|
|
@@ -355,12 +408,18 @@ Source: [packages/server/src/middleware/auth.ts:123-134](../../../packages/serve
|
|
|
355
408
|
|
|
356
409
|
```json
|
|
357
410
|
// 200 authenticated
|
|
358
|
-
{ "authenticated": true }
|
|
411
|
+
{ "authenticated": true, "badge": "DEV" }
|
|
359
412
|
|
|
360
413
|
// 401 not authenticated
|
|
361
|
-
{ "authenticated": false }
|
|
414
|
+
{ "authenticated": false, "badge": "DEV" }
|
|
362
415
|
```
|
|
363
416
|
|
|
417
|
+
`badge` echoes the `HALO_BADGE` env (trimmed; `null` when unset) on **both**
|
|
418
|
+
branches — this is the admin's first request, login page included, so the tab
|
|
419
|
+
can brand its favicon + title from the very first paint without a second
|
|
420
|
+
endpoint or a poll. Corollary: the value is readable **without authenticating**,
|
|
421
|
+
so it must stay a label, not a secret. See [env.md](./env.md).
|
|
422
|
+
|
|
364
423
|
### POST `/api/auth/change-password`
|
|
365
424
|
|
|
366
425
|
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 +576,7 @@ Two shapes, selected by `rootOnly`:
|
|
|
517
576
|
"source": "explorer",
|
|
518
577
|
"createdAt": "2026-04-30T08:00:00Z",
|
|
519
578
|
"updatedAt": "2026-04-30T08:05:00Z",
|
|
520
|
-
"
|
|
579
|
+
"exchangeCount": 12,
|
|
521
580
|
"parentSessionId": null,
|
|
522
581
|
"contextTokens": 5975,
|
|
523
582
|
"totalOutputTokens": 6058,
|
|
@@ -531,9 +590,11 @@ Two shapes, selected by `rootOnly`:
|
|
|
531
590
|
|
|
532
591
|
Archived sessions are excluded by default; pass `?includeArchived=1` to include them.
|
|
533
592
|
|
|
593
|
+
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.
|
|
594
|
+
|
|
534
595
|
### GET `/api/sessions/logs/:id?projectId=<abs>`
|
|
535
596
|
|
|
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.
|
|
597
|
+
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
598
|
|
|
538
599
|
```json
|
|
539
600
|
// 200 — same shape as the on-disk JSON
|
|
@@ -548,11 +609,38 @@ Returns the full session file. If only `rawMessages` is present (sub-agent sessi
|
|
|
548
609
|
"messageCount": 12,
|
|
549
610
|
"contextTokens": 5975,
|
|
550
611
|
"totalOutputTokens": 6058,
|
|
612
|
+
"archiveCount": 2,
|
|
551
613
|
"messages": [ /* SessionMessage[] — see design/storage.md */ ],
|
|
552
614
|
"rawMessages": [ /* optional */ ]
|
|
553
615
|
}
|
|
554
616
|
```
|
|
555
617
|
|
|
618
|
+
`messageCount` here is the **file's own** field (`messages.length`, shrinks on archive) — not the list endpoint's lifetime `exchangeCount`.
|
|
619
|
+
|
|
620
|
+
### GET `/api/sessions/logs/:id/archive/:n?projectId=<abs>`
|
|
621
|
+
|
|
622
|
+
Source: `packages/server/src/routes/session-archive.ts`
|
|
623
|
+
|
|
624
|
+
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`).
|
|
625
|
+
|
|
626
|
+
```json
|
|
627
|
+
// 200
|
|
628
|
+
{ "messages": [ /* SessionMessage[] — oldest-first within the segment */ ] }
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
Failure modes, in the order checked:
|
|
632
|
+
|
|
633
|
+
| Status | Body | When |
|
|
634
|
+
|---|---|---|
|
|
635
|
+
| 500 | `session manager not initialized` | server not fully booted |
|
|
636
|
+
| 400 | `projectId required` | missing query param |
|
|
637
|
+
| 400 | `Invalid session id` | `id` fails `isSafeIdSegment` (path-traversal guard) |
|
|
638
|
+
| 400 | `Invalid segment number` | `n` not a positive integer |
|
|
639
|
+
| 404 | `Session not found` | no such session in this workspace |
|
|
640
|
+
| 404 | `Segment not found` | `n > archiveCount` (uncommitted / nonexistent), or the segment file can't be read |
|
|
641
|
+
|
|
642
|
+
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).
|
|
643
|
+
|
|
556
644
|
### GET `/api/sessions/goal?projectId=<abs>`
|
|
557
645
|
|
|
558
646
|
Source: `packages/server/src/routes/sessions.ts` (`findLatestGoal` in `agents/goal-mode.ts`)
|
|
@@ -629,9 +717,9 @@ Resolves declared schema (from `models/<id>.yaml` `secrets:` and `skills/<id>/co
|
|
|
629
717
|
|
|
630
718
|
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
719
|
|
|
632
|
-
### POST `/api/
|
|
720
|
+
### POST `/api/wechat/login/start`
|
|
633
721
|
|
|
634
|
-
Source: [packages/server/src/routes/
|
|
722
|
+
Source: [packages/server/src/routes/wechat.ts:39-43](../../../packages/server/src/routes/wechat.ts#L39-L43)
|
|
635
723
|
|
|
636
724
|
```json
|
|
637
725
|
// Request
|
|
@@ -641,7 +729,7 @@ Source: [packages/server/src/routes/weixin.ts:42-46](../../../packages/server/sr
|
|
|
641
729
|
{ "qrcodeUrl": "https://...", "sessionKey": "abc123" }
|
|
642
730
|
```
|
|
643
731
|
|
|
644
|
-
### POST `/api/
|
|
732
|
+
### POST `/api/wechat/login/wait`
|
|
645
733
|
|
|
646
734
|
```json
|
|
647
735
|
// 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
|
|
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
|
|
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 (
|
|
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
|
|
249
|
-
better-sqlite3 **unconditionally** (not
|
|
250
|
-
ABI is always re-pinned to the bundled
|
|
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
|
package/bundled-docs/dev/env.md
CHANGED
|
@@ -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
|
-
|
|
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'
|
|
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
|
-
|
|
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
|