@turmind/halo 2.2.1 → 2.2.3

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.
@@ -711,7 +711,7 @@ Rows are never pruned.
711
711
  - `schema.sql` / each db's `CREATE_SQL` always describes the FULL current shape, so a fresh db is complete after the CREATEs alone
712
712
  - Every change to an already-existing db gets a numbered slot in that file's ordered migration list — `HALO_MIGRATIONS` (`db/index.ts`), `CRON_MIGRATIONS` (`db/cron-db.ts`), `A2A_MIGRATIONS` (`db/a2a-db.ts`), `EVO_MIGRATIONS` (`db/evo-db.ts`); `channel-db.ts` / `runs-db.ts` currently pass an empty list. `runMigrations(sqlite, list)` (`db/migrate.ts`) runs `list[user_version..]` in order, each in its own transaction, stamping `PRAGMA user_version = i + 1` after each
713
713
  - A fresh db is also at `user_version 0` and runs the whole list, so every slot must be a no-op against the current shape (`addColumnIfMissing`, `CREATE … IF NOT EXISTS`). A CHECK constraint can't be altered in SQLite: the slot rebuilds the table (create `_v2`, copy rows, drop, rename) and skips when `sqlite_master` already shows the new constraint — `HALO_MIGRATIONS` v2 (`disabled_items.scope` admits `'extension'`) is the pattern
714
- - Current slots worth knowing: `HALO_MIGRATIONS` v2 — the `disabled_items` rebuild above (rows kept). `HALO_MIGRATIONS` v3 — `agent_sessions.output TEXT`, `agent_sessions.last_activity_at TEXT` (session store 2.0: the files carry no metadata). `CRON_MIGRATIONS` v5 — `cron_jobs.working_dir TEXT NULL` (the job's folder — the session's `working_dir`), `cron_jobs.stateless INTEGER NOT NULL DEFAULT 0` (1 = fresh session every run), `cron_runs.session_id TEXT NULL` (the session that run used). `CRON_MIGRATIONS` v6 — `cron_runs.workspace_path TEXT NULL` (the workspace the run fired against, where a stateless run's session lives), backfilled from the job row (`UPDATE … SET workspace_path = (SELECT workspace_path FROM cron_jobs …) WHERE workspace_path IS NULL`), so rows whose job is already gone stay NULL; column semantics in [cron.md](cron.md). `CRON_MIGRATIONS` v7 — `cron_jobs.webhook_secret TEXT NULL` (set = webhook-triggered job) plus the `cron_events` table (one row per webhook delivery, `UNIQUE(job_id, delivery_id)`; created by `CREATE_SQL`'s `IF NOT EXISTS`, so the slot itself only adds the column); cron.db at `user_version` 7 after it. `A2A_MIGRATIONS` v1 — `a2a_tasks.result_files TEXT`; v2 — `agentcore_invocations.result`, `result_files`, `superseded_by` (all `TEXT`, for a table an earlier build created without them; a2a.db at `user_version` 2 after it). `EVO_MIGRATIONS` v1 — `evolution_applies.approved_by TEXT NOT NULL DEFAULT 'reviewer'` (`'auto'` for L2/L3), `notified_at INTEGER`, `rolled_back_at INTEGER`; `evolution_runs.has_observation INTEGER NOT NULL DEFAULT 0`, `source_access TEXT` (`full` / `workspace` / `readonly`, or `webhook` for a session that took cron webhook events; null on pre-existing rows → never auto-applied); v2 — `evolution_runs.attempts_base INTEGER NOT NULL DEFAULT 0` (`attempts` at the latest retry; the retry budget counts from it), and v1's adds re-run (idempotent) for dev dbs stamped v1 without `source_access`; see [evolution.md](evolution.md)
714
+ - Current slots worth knowing: `HALO_MIGRATIONS` v2 — the `disabled_items` rebuild above (rows kept). `HALO_MIGRATIONS` v3 — `agent_sessions.output TEXT`, `agent_sessions.last_activity_at TEXT` (session store 2.0: the files carry no metadata). `CRON_MIGRATIONS` v5 — `cron_jobs.working_dir TEXT NULL` (the job's folder — the session's `working_dir`), `cron_jobs.stateless INTEGER NOT NULL DEFAULT 0` (1 = fresh session every run), `cron_runs.session_id TEXT NULL` (the session that run used). `CRON_MIGRATIONS` v6 — `cron_runs.workspace_path TEXT NULL` (the workspace the run fired against, where a stateless run's session lives), backfilled from the job row (`UPDATE … SET workspace_path = (SELECT workspace_path FROM cron_jobs …) WHERE workspace_path IS NULL`), so rows whose job is already gone stay NULL; column semantics in [cron.md](cron.md). `CRON_MIGRATIONS` v7 — `cron_jobs.webhook_secret TEXT NULL` (set = webhook-triggered job) plus the `cron_events` table (one row per webhook delivery, `UNIQUE(job_id, delivery_id)`; created by `CREATE_SQL`'s `IF NOT EXISTS`, so the slot itself only adds the column); cron.db at `user_version` 7 after it. `A2A_MIGRATIONS` v1 — `a2a_tasks.result_files TEXT`; v2 — `agentcore_invocations.result`, `result_files`, `superseded_by` (all `TEXT`, for a table an earlier build created without them; a2a.db at `user_version` 2 after it). `EVO_MIGRATIONS` v1 — `evolution_applies.approved_by TEXT NOT NULL DEFAULT 'reviewer'` (`'auto'` for L2/L3), `notified_at INTEGER`, `rolled_back_at INTEGER`; `evolution_runs.has_observation INTEGER NOT NULL DEFAULT 0`, `source_access TEXT` (`full` / `workspace` / `readonly`, null on pre-existing rows → never auto-applied; `webhook` on rows written before 2.2.3, read as not full); v2 — `evolution_runs.attempts_base INTEGER NOT NULL DEFAULT 0` (`attempts` at the latest retry; the retry budget counts from it), and v1's adds re-run (idempotent) for dev dbs stamped v1 without `source_access`; see [evolution.md](evolution.md)
715
715
  - Append only — never reorder or edit a shipped slot. A db opened by a newer halo (user_version > list length) logs a warning and continues
716
716
  - Do not drop or rename existing columns
717
717
  - New channel types: add rows with a new `channel_type` value + define config shape in the channel's `accounts.ts` adapter
@@ -1,8 +1,8 @@
1
1
  # On-device speech-to-text (`stt-local`)
2
2
 
3
- A canvas extension that declares capability `stt-local` can stream microphone audio to the Halo server and get back text. Everything runs on the server: SenseVoice-Small (int8) decodes, Silero VAD cuts the audio into segments, and `sherpa-onnx-node` hosts both models. There is no cloud call and no credential to configure. Model files are downloaded the first time they're needed. Not mounted in AgentCore mode, since that mode runs neither the extension watcher nor any WS surface.
3
+ A canvas extension that declares capability `stt-local` can stream microphone audio to the Halo server and get back text. Everything runs on the server: SenseVoice-Small (int8) decodes, Silero VAD cuts the audio into segments, optionally pyannote segmentation + CAM++ label who spoke (`speakers=1`), and `sherpa-onnx-node` hosts all of them. There is no cloud call and no credential to configure. Model files are downloaded the first time they're needed. Not mounted in AgentCore mode, since that mode runs neither the extension watcher nor any WS surface.
4
4
 
5
- Code: `packages/server/src/stt/models.ts` (model files + downloader), `stt/engine.ts` (recognizer, VAD, decode queue), `routes/stt-ws.ts` (WS route), `extensions/watcher.ts` (`reconcileSttModels`), mounted in `index.ts` next to the Transcribe proxy.
5
+ Code: `packages/server/src/stt/models.ts` (model files + downloader), `stt/engine.ts` (recognizer, VAD, decode queue, diarizer), `stt/speakers.ts` (speaker matching + split, pure), `routes/stt-ws.ts` (WS route), `extensions/watcher.ts` (`reconcileSttModels`), mounted in `index.ts` next to the Transcribe proxy.
6
6
 
7
7
  ## Models and licenses
8
8
 
@@ -11,6 +11,10 @@ Code: `packages/server/src/stt/models.ts` (model files + downloader), `stt/engin
11
11
  | `sense-voice-zh-en-ja-ko-yue-2024-07-17/model.int8.onnx` | 239233841 | SenseVoice-Small int8 (FunAudioLLM / Alibaba), sherpa-onnx export | [FunASR Model License v1.1](https://github.com/modelscope/FunASR/blob/main/MODEL_LICENSE) |
12
12
  | `sense-voice-zh-en-ja-ko-yue-2024-07-17/tokens.txt` | 315894 | its token table | same |
13
13
  | `silero-vad-v4/silero_vad.onnx` | 1807522 | Silero VAD v4 | MIT |
14
+ | `pyannote-segmentation-3-0/model.int8.onnx` | 1540506 | pyannote/segmentation-3.0 (© CNRS), sherpa-onnx int8 export | MIT |
15
+ | `campplus-zh-en-advanced/3dspeaker_speech_campplus_sv_zh_en_16k-common_advanced.onnx` | 28281164 | 3D-Speaker CAM++ zh/en speaker embedding (Alibaba) | Apache-2.0 |
16
+
17
+ All five are one download set (~271 MB): an install that only uses dictation still fetches the two speaker files (~30 MB) — one state machine, one progress bar, accepted. The speaker models are only *loaded* by a `speakers=1` stream.
14
18
 
15
19
  - **FunASR Model License v1.1** allows free use, copying, modification and sharing, provided you credit the source and the authors and keep the model's name (§2.2). The attribution goes on the **halo-hub extension's description page**. The hub ships no weights: the server pulls them from the sources below, the extension never does.
16
20
  - The VAD is the v4 export (1.8 MB). The 643 KB export the first evaluation used exists only on GitHub releases, not on HF or ModelScope. On sherpa's `test_silero_vad.wav`, the two cut identical segments.
@@ -20,8 +24,9 @@ Code: `packages/server/src/stt/models.ts` (model files + downloader), `stt/engin
20
24
 
21
25
  Each file has its sha256 and size pinned in `STT_MODEL_FILES` (`models.ts`), and response headers are never trusted. Each file tries its sources in order, falling back file by file:
22
26
 
23
- 1. **Hugging Face**, pinned to a commit: `csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17@2365baea…` and `csukuangfj/vad@fba88cd2…`.
24
- 2. **ModelScope** `https://www.modelscope.cn/api/v1/models/<repo>/repo?Revision=master&FilePath=<file>` (`pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue`, `pengzhendong/silero-vad` `v4/silero_vad.onnx`). Only this API form 302s to a CDN that honours Range. `resolve/master` returns a full 200 to a Range request.
27
+ 1. **Hugging Face**, pinned to a commit: `csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17@2365baea…`, `csukuangfj/vad@fba88cd2…`, `csukuangfj/sherpa-onnx-pyannote-segmentation-3-0@9403a690…`, `csukuangfj/speaker-embedding-models@0743f301…`.
28
+ 2. **ModelScope** `https://www.modelscope.cn/api/v1/models/<repo>/repo?Revision=master&FilePath=<file>` (`pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue`, `pengzhendong/silero-vad` `v4/silero_vad.onnx`, `pengzhendong/sherpa-onnx-pyannote-segmentation-3-0`, `lijiu09/speaker-embedding-models`). Only this API form 302s to a CDN that honours Range. `resolve/master` returns a full 200 to a Range request.
29
+ 3. **GitHub releases** (CAM++ only): `k2-fsa/sherpa-onnx` release `speaker-recongition-models` (sic), honours Range.
25
30
 
26
31
  Mechanics:
27
32
 
@@ -32,7 +37,7 @@ Mechanics:
32
37
  - **Single flight**: one `ensure()` promise. Concurrent triggers join it.
33
38
  - **Shutdown** (`gracefulShutdown` → `stt.close()`) aborts the running request and keeps the `.part`. The next boot resumes it (logged as `resuming at N bytes`).
34
39
 
35
- Measured on the dev box (temp HOME): HF, all three files, 3.5 s total (228 MB model in 2.5 s). ModelScope-only (HF forced to fail): tokens + VAD in 2.4 s. Model aborted at 43 MB, then resumed from ModelScope in 10.9 s. SIGTERM mid-download then reboot resumed from HF in 2.3 s. Every sha matched.
40
+ Measured on the dev box (temp HOME), before the two speaker files were added: HF, all three files then in the set, 3.5 s total (228 MB model in 2.5 s). ModelScope-only (HF forced to fail): tokens + VAD in 2.4 s. Model aborted at 43 MB, then resumed from ModelScope in 10.9 s. SIGTERM mid-download then reboot resumed from HF in 2.3 s. Every sha matched.
36
41
 
37
42
  ### State machine
38
43
 
@@ -50,7 +55,7 @@ missing ──ensure()──▶ downloading{received,total} ──all files ok
50
55
  ### Triggers
51
56
 
52
57
  - **WS connect** (`routes/stt-ws.ts`): if the model isn't ready, `ensure()` runs, and that socket then receives `model` frames until `ready` or `error`.
53
- - **Extensions reconcile** (`extensions/watcher.ts` → `reconcileSttModels`): after `start()`'s first scan and after every `rescanAndBroadcast()` scan (install / uninstall / watcher event). If any valid, switched-on extension declares `stt-local` and the state is neither `ready` nor `downloading`, it calls `ensure()`. This makes installing the extension the moment the ~240 MB fetch starts, and it retries an `error` on the next rescan. The watcher doesn't run in AgentCore mode.
58
+ - **Extensions reconcile** (`extensions/watcher.ts` → `reconcileSttModels`): after `start()`'s first scan and after every `rescanAndBroadcast()` scan (install / uninstall / watcher event). If any valid, switched-on extension declares `stt-local` and the state is neither `ready` nor `downloading`, it calls `ensure()`. This makes installing the extension the moment the ~271 MB fetch starts, and it retries an `error` on the next rescan. The watcher doesn't run in AgentCore mode.
54
59
 
55
60
  ## Engine
56
61
 
@@ -59,10 +64,23 @@ missing ──ensure()──▶ downloading{received,total} ──all files ok
59
64
  - **Global decode queue**: FIFO, concurrency 1 server-wide, via `decodeAsync` (libuv pool; the event loop keeps running). 10 s of speech decodes in ~0.2–0.3 s.
60
65
  - **Caps → `busy`**: more than **32** segments waiting server-wide, or more than **60 s** of one stream's audio queued or decoding. The stream fails, and its own queued segments are dropped.
61
66
  - Results: `lang` `<|zh|>` → `zh` (`zh` / `en` / `yue` / `ja` / `ko`). A segment whose text is empty after trimming is dropped (no frame), but still counts in the segment log.
67
+ - **Speaker diarizer** (`speakers=1` streams only): `OfflineSpeakerDiarization` (pyannote seg int8 + CAM++) and one `SpeakerEmbeddingExtractor` (CAM++), created by the first `load({ speakers: true })`, then resident like the recognizer. A plain stream never loads them. Both constructors are sync (~0.6 s together, once per process). A load failure is `SttError('engine')` (`speaker labelling unavailable: …`) on that stream, retried on the next; the recognizer and plain streams are unaffected. Measured on the dev box: **+86 MB RSS** at load; after a 57 s four-speaker session the process sat at ~610 MB vs ~480 MB without speakers (onnxruntime arenas), growing ~15–20 MB per further session like the plain path does.
68
+ - `sherpa-onnx-node`'s diarization class only has a sync `process()`. The engine calls the addon's `offlineSpeakerDiarizationProcessAsync(handle, samples, progressCb)` instead (exactly 3 args; libuv pool, same segments), imported from `sherpa-onnx-node/addon.js` — the package has no `exports` map, and the ESM `default` doesn't expose the addon. The call is wrapped in a 60 s timeout (see Known limits). An `abort()` during a segment's labelling stops before the next embedding.
69
+
70
+ ### Speaker labels (`speakers=1`)
71
+
72
+ Per VAD segment, inside the same FIFO job as its decode (so a stream's finals stay in order), only when the decoded text is non-empty:
62
73
 
63
- ### VAD (one per stream)
74
+ 1. **Diarize the segment** (`clustering: { numClusters: -1, threshold: 0.6 }`, `minDurationOn 0.3`, `minDurationOff 0.5`) → local runs with local cluster ids. A segment's VAD cut often holds several people back to back (four.wav's first segment is 0.61–18.03 s, three speakers), which is why a per-segment embedding alone isn't enough (it found 3 of 4 speakers).
75
+ 2. **Local cluster → connection speaker** (`stt/speakers.ts` `createSpeakerTracker`): one CAM++ embedding per cluster, from its runs' audio back to back, capped at **6 s** (`compute` is sync on the event loop: ~9 ms per 0.5 s, ~94 ms per 10 s; the engine yields before each one). Longest cluster first. Cosine against the connection's centroids (running sums of unit embeddings): best **≥ 0.55** → that speaker, centroid updated; else a new speaker. A cluster with **< 1.0 s** of audio never founds a new speaker once one exists: it joins the nearest without moving its centroid. Nothing diarized (pyannote found no speech) → the whole segment is one run with one embedding.
76
+ 3. **Text → runs** (`splitBySpeakerRuns`): SenseVoice's `tokens[]` + `timestamps[]` (seconds into the decoded samples; `tokens.join('') === text`, ITN included, checked on zh / en). Each word token goes to the run containing its timestamp (the later-starting one if runs overlap), else the nearest; punctuation / whitespace tokens stay with the current piece, so a piece never starts with `,`. Consecutive same-speaker tokens form one `final`. A new piece starts at its speaker run's start, clamped between the previous piece's last token and its own first token; the first piece starts at the segment's padded start, the last ends at its padded end, so the finals tile the segment without overlap. A piece that would span zero seconds (overlapping runs clamp its start onto its only token) is merged into its neighbour, and same-speaker neighbours left by that merge are joined. Tokens that don't spell the trimmed text (or no timestamps) → the whole text under the speaker with the most audio in the segment, the same rule as the Transcribe proxy's `splitBySpeaker`.
77
+ 4. **Wire label**: `"0"`, `"1"`… numbered per connection in order of first *sent* appearance.
64
78
 
65
- Silero v4, 512-sample windows at 16 kHz, internal buffer 30 s. Constants in `engine.ts`:
79
+ Thresholds: CAM++ cosine on four.wav, pairwise over its nine reference turns (all ≥ 1 s, each embedding capped at 6 s), was 0.75–0.83 for the same speaker (6 pairs) and 0.03–0.52 between different ones (30 pairs); in the engine every match threshold from 0.45 to 0.7 gave the same labels, so 0.55 sits in the gap. **Acceptance** (sherpa's 4-speaker zh test `four.wav`, 56.9 s, 16 kHz mono, converted to s16le and streamed through `createSttEngine()` with the real models in 100 ms frames, `speakers` on): 8 finals labelled `0 1 2 | 0 | 3 | 3 | 2 | 0`, matching the reference (`A B B C | A | D | D | C | A`, sherpa's two S1 runs fall in one piece here) up to renaming, the first segment split at 7.03 s and 13.78 s into its three speakers' sentences. Total 3.4 s for 57 s of audio (1.8 s without speakers); longest event-loop gap 70 ms.
80
+
81
+ ### VAD (one per open stream, pooled)
82
+
83
+ Silero v4, 512-sample windows at 16 kHz, internal buffer 30 s. Each open stream holds one; on `end()` (after the flush) or `abort()` it is `reset()` and goes back to a per-engine pool that `open()` draws from first, so the pool only grows to the most streams ever open at once and pooled VADs are never freed (~4.6 MB native each). A `new Vad` per stream left that native memory to a GC finalizer the small JS heap rarely runs: +150 MB RSS over 150 streams through the engine (+690 MB for raw new-and-feed), flat with `gc()`. The per-segment recognizer / CAM++ streams are still finalizer-freed (the addon has no release call), ≈ 0.2 MB per decode. Constants in `engine.ts`:
66
84
 
67
85
  | Constant | Value | Why |
68
86
  |---|---|---|
@@ -81,12 +99,15 @@ Each stream keeps a 30 s ring of raw samples (it must reach back past a 20 s seg
81
99
 
82
100
  `noServer` ws server behind `ws/upgrade-router.ts`, same structure as the Transcribe proxy (`routes/transcribe-ws.ts`).
83
101
 
102
+ - **Query**: `ext=<id>` (required) and optional `speakers=1`. Only the exact value `1` turns speaker labels on (loading the diarizer if this is the first such stream); absent or any other value = no labels, byte-identical frames to before. voice-input never sends it; htrans ≥ 1.0.10 does when "Identify speakers" is ticked with the on-device engine.
103
+
84
104
  - **Handshake**: login cookie (`isAuthenticated(getTokenFromCookieHeader(cookie))`), else **401**. `ext` must be installed, switched on and declare `stt-local`, else **403**. Frames are capped at 1 MiB (`maxPayload`). Every socket has an `error` listener, so a protocol violation (oversize → 1009, bad UTF-8 → 1007) closes only that socket.
85
105
  - **Server → client** (text JSON):
86
106
  - `{"type":"model","state":"missing"|"downloading"|"ready"|"error","received"?,"total"?,"message"?}`: always the **first** frame. While the model isn't ready, more `model` frames follow (progress ≤ 2/s, plus every state change). `received` / `total` are present only for `downloading`, `message` only for `error`.
87
107
  - `{"type":"ready"}`: model ready **and** engine loaded. Audio is accepted from here on.
88
108
  - `{"type":"final","start":<s>,"end":<s>,"text":"…","lang":"zh"|"en"|"yue"|"ja"|"ko"}`: one per non-empty segment. `start` / `end` are seconds (3 decimals) from this connection's first accepted audio sample.
89
- - `{"type":"error","code":"model"|"engine"|"busy"|"bad-request"|"io","message":"…"}`, then close **1011**. `model` = download failed (the reason is also in the preceding `model` frame's `message`), `engine` = native load / VAD / decode failure, `busy` = queue caps above, `bad-request` = a text frame other than `{"type":"end"}`, `io` = anything else.
109
+ - With `speakers=1`, every `final` also carries `"speaker":"0"|"1"|…`: a **string**, numbered per connection in order of first appearance — the same shape as the Transcribe proxy's (htrans shows `Number(speaker) + 1` as "Speaker N"). A segment several people spoke in arrives as **one `final` per speaker run** (see [Speaker labels](#speaker-labels-speakers1)), in time order, non-overlapping, never zero-length, sharing the segment's `lang`; their `start` / `end` tile the segment's padded bounds.
110
+ - `{"type":"error","code":"model"|"engine"|"busy"|"bad-request"|"io","message":"…"}`, then close **1011**. `model` = download failed (the reason is also in the preceding `model` frame's `message`), `engine` = native load / VAD / decode / diarizer load or run failure, `busy` = queue caps above, `bad-request` = a text frame other than `{"type":"end"}`, `io` = anything else.
90
111
  - **Client → server**:
91
112
  - binary = PCM s16le mono 16 kHz, any frame length (an odd trailing byte carries over). **Binary sent before `ready` is dropped**, because there's no stream yet to feed. A client should start sending only after `ready` (`start` / `end` count from the first audio sent after it).
92
113
  - text `{"type":"end"}`: flush the VAD, wait for every remaining segment of this connection to be decoded and its `final` sent, then close **1000**. Sent before `ready`, it closes 1000 at once (nothing was accepted).
@@ -95,12 +116,15 @@ Each stream keeps a 30 s ring of raw samples (it must reach back past a 20 s seg
95
116
 
96
117
  ## Logs
97
118
 
98
- `[STT] …` only, with counts and durations: `stream open ext=…`, `stream closed after Ns ext=… segments=N audio=Ns`, `stream error ext=… code=…`, `engine loaded in Nms (threads=N)` / `engine load failed: …`, `model download start`, `downloaded <file> from <source> (N MB in Ns)`, `download <file> from <source> failed: <reason>`, `<file>: resuming at N bytes`. **Recognized text and audio are never logged.** The server's default level is `warn`, so these `console.log` lines show up at `general.logging.level: info` (or `HALO_LOG_LEVEL=info`).
119
+ `[STT] …` only, with counts and durations: `stream open ext=…[ speakers=1]`, `stream closed after Ns ext=… segments=N audio=Ns[ speakers=N]`, `stream error ext=… code=…`, `engine loaded in Nms (threads=N)` / `engine load failed: …`, `speaker diarizer loaded in Nms` / `speaker diarizer load failed: …`, `model download start`, `downloaded <file> from <source> (N MB in Ns)`, `download <file> from <source> failed: <reason>`, `<file>: resuming at N bytes`. **Recognized text and audio are never logged.** The server's default level is `warn`, so these `console.log` lines show up at `general.logging.level: info` (or `HALO_LOG_LEVEL=info`).
99
120
 
100
121
  ## Known limits
101
122
 
102
- - **Proxies**: neither `fetch` nor `node:http(s)` reads `HTTPS_PROXY` / `HTTP_PROXY` on Node 22 by default (verified: with an unreachable `HTTPS_PROXY`, both connected directly). `NODE_USE_ENV_PROXY=1` (experimental in Node 22, prints `UNDICI-EHPA` warnings) makes both honour it. A server behind a mandatory egress proxy can't download the models without it. The workaround is to drop the three files into `~/.halo/global/stt-models/` by hand with the exact names and sizes above.
103
- - ~400 MB resident once any stream has loaded the engine (no idle release, see above). The decode burst uses up to 4 cores.
123
+ - **Proxies**: neither `fetch` nor `node:http(s)` reads `HTTPS_PROXY` / `HTTP_PROXY` on Node 22 by default (verified: with an unreachable `HTTPS_PROXY`, both connected directly). `NODE_USE_ENV_PROXY=1` (experimental in Node 22, prints `UNDICI-EHPA` warnings) makes both honour it. A server behind a mandatory egress proxy can't download the models without it. The workaround is to drop the five files into `~/.halo/global/stt-models/` by hand with the exact names and sizes above.
124
+ - ~400 MB resident once any stream has loaded the engine (no idle release, see above), plus ~90–130 MB once a `speakers=1` stream has loaded the diarizer. The decode burst uses up to 4 cores.
125
+ - **Speaker labels are per connection**: a reconnect (htrans's Pause → Resume starts a new run) renumbers from `"0"`, and the same person may get a different number. Labels are never names. Overlapping speech, far-field / room microphones, telephone audio and more than ~4 speakers are **unmeasured** — the only acceptance file is sherpa's clean four-speaker zh test. A speaker whose only turns are under 1 s is folded into the nearest existing one. A speaker change inside a SenseVoice token (rare; tokens are ~0.1–0.3 s) is cut at the token boundary.
126
+ - **The direct-wav acceptance above is the optimistic case.** Through a real browser capture path (htrans browser e2e: Chrome captures the 16 kHz source at 48 kHz, the htrans worklet downsamples back to 16 kHz) one 2.5 s stretch of four.wav (11.2–13.8 s, reference speaker B) was labelled as another speaker in every run, and one run produced a 5th speaker for a misrecognised D sentence. The same 48 → 16 kHz round trip done with ffmpeg reproduces it, and real microphones always go through this path.
127
+ - **Diarization timeout**: one native diarization call is capped at 60 s (`DIAR_TIMEOUT_MS`), then the stream fails `engine` (`speaker diarization failed: timed out`). sherpa-onnx-node 1.13.8's async diarization worker has no `OnError`, so a native exception would otherwise leave its promise pending and stall the server-wide decode queue until restart. The native call itself can't be cancelled: if it is really still running, it keeps one libuv thread busy until it returns.
104
128
  - One decode at a time: with many concurrent speakers, latency grows linearly until `busy`.
105
129
  - No partial results (SenseVoice is non-streaming): a final arrives ~0.5 s after the speaker pauses, at most 20 s after a segment starts.
106
130
  - Desktop (win-x64 / darwin-arm64): `stage-runtime.mjs` `installSherpaBinary()` ships the target's `sherpa-onnx-<platform>-<arch>` package (see [dev/desktop-packaging.md](../dev/desktop-packaging.md)). A build without it fails streams with `engine`, never server boot. The macOS addon declares minimum 14.0; older macOS is unverified.
@@ -128,7 +128,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
128
128
 
129
129
  | Type | Source | Purpose |
130
130
  |---|---|---|
131
- | `state:snapshot` | handler.ts on connect | Initial state (agents, messages, sessionId). On **subscribe / reattach only** it also carries `archiveCount` — the number of committed UI-log archive segments, which the admin's scroll-up loader counts down from (see [Archive anchor](#archive-anchor-in-statesnapshot)) — and `accessLevel` (`'workspace' | 'readonly' | null`, null = full), which seeds the admin's access-level selector. Subscribe includes `accessLevel` only when the session already exists; for a not-yet-created session it's omitted, and the admin leaves the selector alone when the field is absent (so a level picked before the first send isn't reset). The subscribe / reattach rows leave out the log's `tool_call` / `tool_result` rows (`adminSnapshotRows`) — the chat renders each tool call from the assistant row's `contentBlocks`; usage / context rows stay. The on-disk UI log and the other channels keep them. The admin reuses the objects of unchanged rows on replace (`reconcileMessages`), so memoized rows skip re-render |
131
+ | `state:snapshot` | handler.ts on connect | Initial state (agents, messages, sessionId). On **subscribe / reattach only** it also carries `archiveCount` — the number of committed UI-log archive segments, which the admin's scroll-up loader counts down from (see [Archive anchor](#archive-anchor-in-statesnapshot)) — and `accessLevel` (`'workspace' | 'readonly' | null`, null = full), which seeds the admin's access-level selector. Subscribe includes `accessLevel` only when the session already exists; for a not-yet-created session it's omitted, and the admin leaves the selector alone when the field is absent (so a level picked before the first send isn't reset). The subscribe / reattach rows leave out the log's `tool_call` / `tool_result` rows (`adminSnapshotRows`, `sessions/session-types.ts`) — the chat renders each tool call from the assistant row's `contentBlocks`; usage / context rows stay. The admin's REST reads of the same data (`GET /api/sessions/logs/:id` and the archive segment route) apply the same filter, so every admin read path carries the same rows; `messageCount` there still counts the full active log. The on-disk UI log and the other channels keep them. The admin reuses the objects of unchanged rows on replace (`reconcileMessages`), so memoized rows skip re-render |
132
132
  | `exchange:deleted` | `handleExchangeDelete` (this client, when subscribed to the session) | An `exchange:delete` landed — `{sessionId, deletedIds, userOrdinal, userRows}`: `deletedIds` is every soft-deleted row id of the active log (idempotent), `userOrdinal` the turn just deleted, `userRows` the log's user rows without `taskId` (deleted ones included — the count the ordinal runs over). The admin flags those rows plus the `userOrdinal` turn's span (rows this tab created live carry client ids the server log doesn't know) — no log replace. When the tab's own count differs from `userRows` (a turn another connection sent with `localEcho`, a `sendFailed` bubble) it flags the ids only and re-sends `subscribe`, whose snapshot corrects the log |
133
133
  | `chat:ack` | `handleChat` | Chat with `clientMsgId` is persisted in the session log — releases the client's pending-ack entry (see [Chat delivery](#chat-delivery-ack--resend--dedup)) |
134
134
  | `__pong__` | `__ping__` handler | Reply to the client's application-level liveness probe |
@@ -98,7 +98,7 @@ Installed extensions are directories under `~/.halo/global/extensions/<id>/` wit
98
98
  | DELETE | `/api/extensions/:id` | cookie | Remove the directory and drop its on / off entry. 400 for an invalid id shape, 404 if not installed. Returns `{ok, id}` |
99
99
  | GET | `/api/extensions/:id/:version/:token/<asset>` | **path token, no cookie** | Static file from the extension directory. 401 unless `verifyScopedToken(token,'ext')`; 404 if `version` ≠ installed version or the asset escapes the directory. Response headers: `Access-Control-Allow-Origin: *`, `Referrer-Policy: no-referrer`, `Cache-Control: public, max-age=31536000, immutable` (the version segment is the cache key) |
100
100
  | WS | `/api/transcribe/stream?ext=<id>&lang=<auto\|xx-XX\|xx-XX,yy-YY,…>[&speakers=1]` | cookie | Streaming transcription proxy to Amazon Transcribe (`routes/transcribe-ws.ts`; not mounted in AgentCore mode). Handshake 401 without the login cookie, 403 unless `ext` is installed, switched on and declares capability `transcribe`. Client → binary PCM s16le mono 16 kHz, text `{"type":"end"}` (flush, server closes 1000). Server → `{"type":"ready"}` (on connect; stream audio right after it), `{"type":"partial"\|"final",start,end,text,lang}` (seconds from the stream's first audio byte); with `speakers=1` (speaker labels, off by default) a `final` also carries `speaker` (`"0"`, `"1"`… — Transcribe's per-stream label, not a name; absent when Transcribe labelled nothing) and a result spoken by several people arrives as one `final` per speaker, `partial` never has it — without `speakers=1` each result is one `final` without `speaker`; `{"type":"error",code,message}` then close; `code` ∈ `credentials` \| `denied` \| `limit` \| `bad-request` \| `io`. Region / languages / credentials from the extension's `ext-<id>` settings (default chain when no keys) — see [design](../design/canvas-extensions.md#streaming-transcription-proxy) |
101
- | WS | `/api/stt/stream?ext=<id>` | cookie | On-device speech-to-text — SenseVoice + Silero VAD via sherpa-onnx, no cloud (`routes/stt-ws.ts`; not mounted in AgentCore mode). Handshake 401 without the login cookie, 403 unless `ext` is installed, switched on and declares capability `stt-local`. Server → `{"type":"model",state,received?,total?,message?}` first (`state` ∈ `missing` \| `downloading` \| `ready` \| `error`; a missing model is downloaded from here, progress pushed ≤ 2/s), `{"type":"ready"}` once model + engine are loaded (binary sent before it is dropped), `{"type":"final",start,end,text,lang}` per VAD segment (seconds from the connection's first accepted sample; `lang` ∈ `zh` \| `en` \| `yue` \| `ja` \| `ko`), `{"type":"error",code,message}` then close 1011; `code` ∈ `model` \| `engine` \| `busy` \| `bad-request` \| `io`. Client → binary PCM s16le mono 16 kHz, text `{"type":"end"}` (remaining finals, then close 1000). No `lang` parameter — see [design/stt-local.md](../design/stt-local.md#ws-apisttstreamextid) |
101
+ | WS | `/api/stt/stream?ext=<id>[&speakers=1]` | cookie | On-device speech-to-text — SenseVoice + Silero VAD via sherpa-onnx, no cloud (`routes/stt-ws.ts`; not mounted in AgentCore mode). Handshake 401 without the login cookie, 403 unless `ext` is installed, switched on and declares capability `stt-local`. Server → `{"type":"model",state,received?,total?,message?}` first (`state` ∈ `missing` \| `downloading` \| `ready` \| `error`; a missing model is downloaded from here, progress pushed ≤ 2/s), `{"type":"ready"}` once model + engine are loaded (binary sent before it is dropped), `{"type":"final",start,end,text,lang}` per VAD segment (seconds from the connection's first accepted sample; `lang` ∈ `zh` \| `en` \| `yue` \| `ja` \| `ko`); with `speakers=1` (on-device speaker labels — only that value; pyannote + CAM++, loaded on the first such stream) every `final` also carries `speaker` (`"0"`, `"1"`… numbered per connection in order of appearance, not a name — same shape as the Transcribe proxy's) and a segment spoken by several people arrives as one `final` per speaker run, in time order, non-overlapping — without it frames are unchanged; `{"type":"error",code,message}` then close 1011; `code` ∈ `model` \| `engine` \| `busy` \| `bad-request` \| `io`. Client → binary PCM s16le mono 16 kHz, text `{"type":"end"}` (remaining finals, then close 1000). No `lang` parameter — see [design/stt-local.md](../design/stt-local.md#ws-apisttstreamextid) |
102
102
  | GET | `/api/jupyter/status` | cookie | Notebook kernels (`routes/jupyter.ts`; not mounted in AgentCore mode). `{ enabled, available, version?, error?, hint? }` — never starts the server; disabled → `enabled:false, available:false`; Jupyter missing → `available:false` + `error` + `hint: 'pip install jupyter_server ipykernel'` |
103
103
  | GET | `/api/jupyter/kernelspecs` | cookie | `{ default, specs: [{ name, displayName, language }] }`. Starts the local jupyter server on demand; 503 `{error, hint?}` when disabled / unavailable / it fails to start |
104
104
  | POST | `/api/jupyter/kernels` | cookie | Body `{ projectId, path, name }` (`path` = workspace-relative notebook path, as in `init.file.path`). 400 for a missing field, unknown project, path outside the workspace or a missing directory. Starts kernel `name` with cwd = the notebook's directory → `{ id, name }` |
@@ -130,7 +130,7 @@ Unified session log API — list + read session files across all agents.
130
130
  | Method | Path | Purpose |
131
131
  |---|---|---|
132
132
  | 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-panel session tabs). A `projectId` with no `.halo/` (plain or missing directory) returns `{sessions: [], nextCursor: null}` without scaffolding it — a list never turns a directory into a workspace |
133
- | GET | `/api/sessions/logs/:id?projectId=` | Full session log — the **active** file only; archived history is a separate call. `projectId` is required: the row names the session's directory (404 without it) |
133
+ | GET | `/api/sessions/logs/:id?projectId=` | Full session log — the **active** file only, without `tool_call` / `tool_result` rows; archived history is a separate call. `projectId` is required: the row names the session's directory (404 without it) |
134
134
  | GET | `/api/sessions/logs/:id/archive/:n?projectId=` | One archived UI-log segment — see [detail](#get-apisessionslogsidarchivenprojectidabs) |
135
135
  | DELETE | `/api/sessions/logs/:id?projectId=` | Permanently delete the session and all its descendants — log files (with archive segments) and `agent_sessions` rows. Returns `{ok, deleted}` |
136
136
  | PATCH | `/api/sessions/logs/:id?projectId=` | Rename a session (admin-only; body `{title}`) — updates `agent_sessions.title` only (the row is the title's only home; no file is touched — an internal-agent session's title lives in the global internal-sessions row). 400 without `projectId` / `title`, 404 for an unknown id. Accepts any session id (root or sub-agent); the sidebar exposes the rename affordance on every row |
@@ -857,7 +857,7 @@ Rows are served from the `agent_sessions` metadata columns (`title` / `exchange_
857
857
 
858
858
  ### GET `/api/sessions/logs/:id?projectId=<abs>`
859
859
 
860
- The session's `agent_sessions` row (metadata) plus its **active** UI log and model history. Older exchanges that archiving moved out are fetched per-segment via [the archive endpoint](#get-apisessionslogsidarchivenprojectidabs). If the UI log is empty and only raw history exists (sub-agent sessions from SessionManager), `convertRawMessages()` transforms it into the display `messages` array on the fly. `projectId` is required: the row names the session's directory (404 without it, or with no row / no UI log). Opening a 1.x session here migrates it; the response then carries the back-filled row.
860
+ The session's `agent_sessions` row (metadata) plus its **active** UI log and model history. Older exchanges that archiving moved out are fetched per-segment via [the archive endpoint](#get-apisessionslogsidarchivenprojectidabs). `messages` leaves out the log's `tool_call` / `tool_result` rows — the same rows as the WS subscribe / reattach snapshot (`adminSnapshotRows`, see [ws.md](../design/ws.md)); each tool call and its output ride on the assistant row's `contentBlocks`. If the UI log is empty and only raw history exists (sub-agent sessions from SessionManager), `convertRawMessages()` transforms it into the display `messages` array on the fly (user / assistant rows only, so nothing to filter). `projectId` is required: the row names the session's directory (404 without it, or with no row / no UI log). Opening a 1.x session here migrates it; the response then carries the back-filled row.
861
861
 
862
862
  ```json
863
863
  // 200
@@ -874,24 +874,24 @@ The session's `agent_sessions` row (metadata) plus its **active** UI log and mod
874
874
  "contextTokens": 5975,
875
875
  "totalOutputTokens": 6058,
876
876
  "archiveCount": 2,
877
- "messages": [ /* SessionMessage[] — see design/storage.md */ ],
877
+ "messages": [ /* SessionMessage[] without tool_call / tool_result rows — see design/storage.md */ ],
878
878
  "rawMessages": [ /* model history */ ],
879
879
  "output": "...", // when set
880
880
  "lastActivityAt": "..." // when set
881
881
  }
882
882
  ```
883
883
 
884
- `messageCount` is the active UI log's length (shrinks on archive); `exchangeCount` is the row's lifetime count, as in the list endpoint. `archiveCount` = committed segments on disk. The 1.x `version` field is gone.
884
+ `messageCount` is the active UI log's full length, `tool_call` / `tool_result` rows included — so it can exceed `messages.length` (shrinks on archive); `exchangeCount` is the row's lifetime count, as in the list endpoint. `archiveCount` = committed segments on disk. The 1.x `version` field is gone.
885
885
 
886
886
  ### GET `/api/sessions/logs/:id/archive/:n?projectId=<abs>`
887
887
 
888
888
  Source: `packages/server/src/routes/session-archive.ts`
889
889
 
890
- One archived UI-log segment, gunzipped server-side. Works for root and sub-sessions alike: a sub-session's segment is addressed by its full id, URL-encoded (`r1>c1` → `r1%3Ec1`). `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`).
890
+ One archived UI-log segment, gunzipped server-side, with the `tool_call` / `tool_result` rows left out (`adminSnapshotRows`, as in the session log above). Works for root and sub-sessions alike: a sub-session's segment is addressed by its full id, URL-encoded (`r1>c1` → `r1%3Ec1`). `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`).
891
891
 
892
892
  ```json
893
893
  // 200
894
- { "messages": [ /* SessionMessage[] — oldest-first within the segment */ ] }
894
+ { "messages": [ /* SessionMessage[] without tool rows — oldest-first within the segment */ ] }
895
895
  ```
896
896
 
897
897
  Failure modes, in the order checked:
@@ -323,7 +323,7 @@ Use only for a new public release; the retained details here are not extra prepa
323
323
  5. **npm**: publish from `packages/cli/dist-pub/` **once**, using the intended dist-tag (`--tag alpha` for an alpha, not `latest`). Confirm `npm view @turmind/halo@x.y.z version` and the intended tag via `npm view @turmind/halo dist-tags.alpha` (or `dist-tags.latest` for stable); both must equal the requested version. Registry replication can lag ~2–3 minutes: wait/recheck, never republish. `dist-pub/` is release-only; desktop staging uses `dist-dev/`. Historical reason: a second v1.3.4 publish put `1.3.4-<sha>` on `latest` for ~90s.
324
324
  6. **GitHub**: `gh release create vx.y.z --notes-from-tag` (or the short CHANGELOG summary); use `--prerelease` for an alpha.
325
325
  7. **Windows exe**: `(cd packages/desktop && HALO_STAGE_FULL=1 CI=true pnpm dist:win)`, then `gh release upload vx.y.z "packages/desktop/dist/Halo Setup x.y.z.exe"`. Verify it appears in `gh release view vx.y.z --json assets`; v1.3.0 and v1.3.1 missed this asset. macOS dmg is the user's step unless requested.
326
- 8. **halo-node binaries**: nothing to build by hand. Publishing the release triggers `.github/workflows/node-go.yml`'s release job (`go vet` + `go test`, then `build.sh`), which uploads `halo-node-linux-{amd64,arm64,armv7}`, `SHA256SUMS` and `install.sh` (with the version stamped in; it isn't listed in `SHA256SUMS`). Wait for it to go green, then check that all five appear in `gh release view vx.y.z --json assets` — the admin's Linux connect command runs this tag's `install.sh`. If the job failed, rerun it with `gh workflow run node-go.yml -f tag=vx.y.z` (`workflow_dispatch`; `--clobber` makes reruns safe). The trigger needs the release created by a person (`gh release create` as yourself), because a release created with `GITHUB_TOKEN` doesn't start other workflows.
326
+ 8. **halo-node binaries**: nothing to build by hand. Publishing the release triggers `.github/workflows/node-go.yml`'s release job (`go vet` + `go test`, then `build.sh`), which uploads `halo-node-linux-{amd64,arm64,armv7}`, `halo-node-windows-amd64.exe`, `SHA256SUMS` and `install.sh` (with the version stamped in; it isn't listed in `SHA256SUMS`). Wait for it to go green, then check that all six appear in `gh release view vx.y.z --json assets` — the admin's Linux connect command runs this tag's `install.sh`. If the job failed, rerun it with `gh workflow run node-go.yml -f tag=vx.y.z` (`workflow_dispatch`; `--clobber` makes reruns safe). The trigger needs the release created by a person (`gh release create` as yourself), because a release created with `GITHUB_TOKEN` doesn't start other workflows.
327
327
 
328
328
  **npm token gotcha**: `npm publish` on this package needs a granular access token created with **"Bypass 2FA"** checked — scope / permission alone yields `403 Two-factor authentication or granular access token with bypass 2fa enabled is required`. `npm whoami` and `npm token list` succeed with a non-bypass token, so neither is a valid pre-flight; check `GET https://registry.npmjs.org/-/npm/v1/tokens` (with the token as bearer) and look for `"bypass_2fa": true` on the token in use before starting a release.
329
329
 
@@ -84,7 +84,7 @@ To list agents or sessions without running a prompt, use the dedicated subcomman
84
84
  ```
85
85
  [ 17:23:02 in 0.0K out 0.1K ctx 5.8K read 5.3K write 0.3K cache 94% e2e 3.3s think medium claude-sonnet-4-6 ]
86
86
  ```
87
- - **json format**: Single JSON object on stdout after completion (`text` has the same final-answer semantics as text format; `notices` holds the same root system notices as an array of strings, without the `ℹ️ ` prefix):
87
+ - **json format**: Single JSON object on stdout after completion (`text` has the same final-answer semantics as text format; `notices` holds the same root system notices as an array of strings, without the `ℹ️ ` prefix; `error` is `null` on success, else the reason — an error event's text, or the root's last-turn error such as a model refusal):
88
88
 
89
89
  ```json
90
90
  {
@@ -106,10 +106,12 @@ halo cli -w /path/to/review-ws "review the diff" --format json | jq '.text'
106
106
  # Pipe content in
107
107
  git diff HEAD~1 | halo cli -w /path/to/review-ws "review this diff"
108
108
 
109
- # Exit code: 0 = success, 1 = error, 130 = SIGINT, 143 = SIGTERM (graceful: session repaired + flushed before exit)
109
+ # Exit code: 0 = success, 1 = error (including a model refusal on the root's last turn), 130 = SIGINT, 143 = SIGTERM (graceful: session repaired + flushed before exit)
110
110
  halo cli "check for issues" && echo "OK" || echo "FAILED"
111
111
  ```
112
112
 
113
+ A refusal on a sub-agent's turn does not change the exit code — it reaches the root as an aborted report, and the run exits 0 if the root then wraps up normally. A later root turn that ends normally clears an earlier refusal.
114
+
113
115
  When stdin is not a TTY, piped input is appended after the positional prompt. With a positional prompt, stdin is only read if its first data arrives within 0.5 s — then to EOF, however long it takes. Otherwise it's ignored, so an open-but-idle stdin (scripts, CI, cron, `ssh host 'halo cli …'` without `-t`) no longer hangs the run. A producer that takes longer than that to print anything gets ignored too; capture it first (`out=$(slow-cmd); halo cli "summarize" <<<"$out"`). Without a positional prompt, stdin *is* the prompt: `halo cli` waits for EOF, and after 1 s of silence prints one `waiting for the prompt on stdin` hint to stderr.
114
116
 
115
117
  ## TUI Mode (interactive)
@@ -10,16 +10,16 @@ halo-node --root ~/proj ──WebSocket──────▶ halo server ──
10
10
 
11
11
  Two clients do the same job, with the same flags and the same behaviour:
12
12
 
13
- - **Linux (x86_64 / arm64 / armv7)**: `halo-node`, a single ~7 MB binary attached to every GitHub release. It needs no Node.js and idles at about 9 MB of memory.
14
- - **macOS / Windows** (or a Linux box that already has the npm package): `halo node` from `npm install -g @turmind/halo`.
13
+ - **Linux (x86_64 / arm64 / armv7) and Windows (amd64)**: `halo-node`, a single binary attached to every GitHub release. It needs no Node.js. The Linux build is ~7 MB and idles at about 9 MB of memory.
14
+ - **macOS** (or a Linux / Windows machine that already has the npm package): `halo node` from `npm install -g @turmind/halo`.
15
15
 
16
16
  Neither needs `halo setup` or model keys, and neither writes anything under `~/.halo/`.
17
17
 
18
- Supported: **Linux and macOS**. Windows works on a best-effort basis (not regularly tested; `node_run` commands go to `cmd.exe`).
18
+ Supported: **Linux, macOS and Windows amd64**. The Go client is tested natively on Linux and Windows CI; macOS uses npm. Windows `node_run` commands go to `cmd.exe`, with UTF-8 output and a GBK fallback.
19
19
 
20
20
  ## 1. Create a token
21
21
 
22
- Admin → **Channels → Linked machines** → **Add machine**. Pick the workspace and a machine name (`a-z 0-9 -`, e.g. `mbp`; unique within the workspace — the agent uses it to address the machine). The dialog shows two commands to copy: one installs `halo-node` on Linux and runs it with `~/.local/bin` prepended to `PATH` (where a non-root install lands), the other runs `halo node` on macOS / Windows. The install link points at the server's own release, so the machine and the server come from the same version.
22
+ Admin → **Channels → Linked machines** → **Add machine**. Pick the workspace and a machine name (`a-z 0-9 -`, e.g. `mbp`; unique within the workspace — the agent uses it to address the machine). The dialog shows two commands to copy: one installs `halo-node` on Linux and runs it with `~/.local/bin` prepended to `PATH` (where a non-root install lands), the other runs the npm `halo node` client. On Windows, use the standalone exe below instead. The install link points at the server's own release, so the machine and the server come from the same version.
23
23
 
24
24
  The token stays on the list row (copy button). Deleting the row revokes it at once and disconnects the machine.
25
25
 
@@ -55,7 +55,18 @@ curl -fsL https://github.com/turmind/halo-agent/releases/latest/download/SHA256S
55
55
  ./halo-node --server https://halo.example.com --root ~/proj
56
56
  ```
57
57
 
58
- **macOS / Windows**:
58
+ ### Windows (amd64)
59
+
60
+ Download `halo-node-windows-amd64.exe` from the [GitHub release](https://github.com/turmind/halo-agent/releases/latest) matching your server (no npm or Node.js needed). Put it in e.g. `C:\halo-node`. In PowerShell:
61
+
62
+ ```powershell
63
+ $env:HALO_NODE_TOKEN = '<token>'
64
+ & 'C:\halo-node\halo-node-windows-amd64.exe' --server https://halo.example.com --root 'C:\Users\alice\proj'
65
+ ```
66
+
67
+ The release's `SHA256SUMS` includes the exe; compare its entry with `Get-FileHash C:\halo-node\halo-node-windows-amd64.exe -Algorithm SHA256`. Use either `/` or `\` in paths; `~/` expands from `USERPROFILE`. Folder mode resolves symlinks and junctions before checking the root. Add `--allow-shell` only if you want commands as this Windows user. Stop the process before replacing the exe during an upgrade.
68
+
69
+ **macOS (npm)**:
59
70
 
60
71
  ```bash
61
72
  npm install -g @turmind/halo # once
@@ -97,7 +108,7 @@ Neither client follows `HTTP(S)_PROXY`. To reach the server through a forward pr
97
108
 
98
109
  ### TLS certificates (`https://` servers)
99
110
 
100
- `halo-node` trusts the system's CA store (`/etc/ssl/certs/…`; `SSL_CERT_FILE` / `SSL_CERT_DIR` point it elsewhere). It falls back to a built-in Mozilla root set only on a system with no CA bundle at all. For a private or corporate CA, install it into the system store, or set `SSL_CERT_FILE`. `halo node` (Node.js) does the opposite: it uses Node's built-in roots plus `NODE_EXTRA_CA_CERTS`, and ignores the system store.
111
+ `halo-node` trusts the system's CA store (the Windows certificate store on Windows; `/etc/ssl/certs/…` on Linux, where `SSL_CERT_FILE` / `SSL_CERT_DIR` can point it elsewhere). It falls back to a built-in Mozilla root set only on a system with no CA bundle at all. For a private or corporate CA, install it into the system store, or on Linux set `SSL_CERT_FILE`. `halo node` (Node.js) does the opposite: it uses Node's built-in roots plus `NODE_EXTRA_CA_CERTS`, and ignores the system store.
101
112
 
102
113
  ### Run it as a service (systemd)
103
114
 
@@ -129,6 +140,34 @@ WantedBy=multi-user.target
129
140
 
130
141
  Network drops, server restarts, a busy token (409) and a lockout (429) are handled inside the process, which keeps running, so `on-failure` is enough. Don't use `Restart=always`: it keeps restarting after a revoked token, and each restart is a failed login. After 5 failures in 15 minutes the server locks out that IP address, which blocks every machine connecting from it. ExecStart takes the path the install script printed. For the npm client, set `ExecStart=/usr/bin/halo node …`. After upgrading with the install script, `systemctl restart halo-node` to run the new binary.
131
142
 
143
+ ### Start at boot on Windows (Task Scheduler)
144
+
145
+ Create `C:\halo-node\start.cmd` (replace the token, server and shared folder). Restrict this directory and wrapper to your account, Administrators and SYSTEM: it contains a plaintext token, and the log can contain file paths. The wrapper keeps the token out of the task's command line and appends logs:
146
+
147
+ ```bat
148
+ @echo off
149
+ setlocal
150
+ set "HALO_NODE_TOKEN=<token>"
151
+ "C:\halo-node\halo-node-windows-amd64.exe" --server https://halo.example.com --root "C:\Users\alice\proj" >>"C:\halo-node\halo-node.log" 2>&1
152
+ exit /b %errorlevel%
153
+ ```
154
+
155
+ Run PowerShell **as administrator** once to register a startup task. Set `$user` to the account that owns the shared files (not necessarily the administrator registering the task):
156
+
157
+ ```powershell
158
+ $user = "$env:COMPUTERNAME\alice"
159
+ $action = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\cmd.exe" -Argument '/d /c "C:\halo-node\start.cmd"' -WorkingDirectory 'C:\halo-node'
160
+ $trigger = New-ScheduledTaskTrigger -AtStartup
161
+ $principal = New-ScheduledTaskPrincipal -UserId $user -LogonType S4U -RunLevel Limited
162
+ $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
163
+ Register-ScheduledTask -TaskName 'halo-node' -Action $action -Trigger $trigger -Principal $principal -Settings $settings
164
+ Start-ScheduledTask -TaskName 'halo-node'
165
+ ```
166
+
167
+ S4U runs hidden without a logged-in desktop or a stored password. It cannot use that user's network credentials (mapped drives / authenticated SMB) or EFS-encrypted files; use a local shared folder. Outbound HTTPS with the Halo token still works. `ExecutionTimeLimit` **must be zero**: Task Scheduler's default stops a task after 72 hours. Do not enable restart-on-failure: exit 1 means a rejected token / other permanent refusal, and restarting repeats failed logins. Network failures already retry inside halo-node.
168
+
169
+ If your server is reached through **Tailscale**, enable Tailscale's **Run unattended** on Windows so it connects before login. halo-node retries until the network is ready. Inspect `C:\halo-node\halo-node.log` and `Get-ScheduledTaskInfo halo-node`; after replacing the token or upgrading the exe, stop and start the task. The append-only log is yours to rotate.
170
+
132
171
  ### Settings on the machine
133
172
 
134
173
  `halo-node` takes its shell timeout only from `HALO_SHELL_TIMEOUT` (ms, default 120 s); grep's default of 50 matches and the 5 MiB command-output cap are fixed. `halo node` (npm) also reads them from the machine's own `~/.halo/secrets/` (`timeout.shell_exec` in `config.yaml`, the `general.limits.*` keys in `settings.yaml`). This only matters on a machine that also runs a halo server with those settings changed.
@@ -11,7 +11,7 @@ Top-level built-in slash commands: `/help` `/evo` `/session` `/agent` `/skill` `
11
11
  | Name | Slash | Type | Purpose |
12
12
  |---|---|---|---|
13
13
  | `help` | `/help` | server | List commands — object commands show only the verbs the user can run; a command is hidden entirely if no verb is runnable |
14
- | `evo` | `/evo [text]` | server | Flat command (no verbs), **full-only**. Trigger self-evolution on the current root session: snapshot the conversation + queue an evo run. Optional text becomes a hint for what to focus on. Works at every `general.evolution.level` (L0 = manual only; L1 also drafts automatically on pre-compact; L2 / L3 may also auto-apply the result — prompt-only / capability changes that pass the gates — then post one `chat:system` line in this session). Hidden from `/help` for non-full users. See [design/evolution.md](../design/evolution.md). |
14
+ | `evo` | `/evo [text]` | server | Flat command (no verbs), **full-only**. Trigger self-evolution on the current root session: snapshot the conversation + queue an evo run. Optional text becomes a hint for what to focus on. Works at every `general.evolution.level` (L0 = manual only; L1 also drafts automatically on pre-compact; L2 / L3 may also auto-apply the result — prompt-only / capability changes that pass the gates — then post one `chat:system` line in this session). The level is the workspace's own: `evolution.level` can be set per workspace in `<ws>/.halo/settings.yaml`, falling back to the global one. Hidden from `/help` for non-full users. See [design/evolution.md](../design/evolution.md). |
15
15
 
16
16
  The rest are noun-verb **object commands**: `/<obj> <verb> [args]`. Some verbs are built-in deterministic code (`SUBCOMMAND_ROUTES`); the others fall through to the same-name skill (LLM, dispatched via `$1`). Bare `/<obj>` or `/<obj> help` lists the verbs available to the user (filtered by access level).
17
17
 
@@ -102,13 +102,13 @@ An installed canvas extension declares its own config in its manifest: `"setting
102
102
 
103
103
  ### General — built-in
104
104
 
105
- Declared in [packages/server/src/settings-schema.ts](../../../packages/server/src/settings-schema.ts) `generalSection()`. The server itself is the implicit declarer. Keys: `language`, `theme`, `agent.max_retries`, `server.*`, `session.*`, `compact.*`, `sandbox.*`, `logging.*`, `observability.*` (read once at boot — restart the server to apply; cron / evolution runs pick a change up on their next run), `evolution.*`, `limits.*`. All `general.*` keys are `globalOnly`: `config.ts` resolves them through `settingsValue()` against `~/.halo/secrets/settings.yaml` only, so a workspace `settings.yaml` cannot override them. Per-workspace layering applies to namespaced `params` / `secrets` (`getServerSecret(ns, key, workspaceRoot)`, `substituteSecrets`).
105
+ Declared in [packages/server/src/settings-schema.ts](../../../packages/server/src/settings-schema.ts) `generalSection()`. The server itself is the implicit declarer. Keys: `language`, `theme`, `agent.max_retries`, `server.*`, `session.*`, `compact.*`, `sandbox.*`, `logging.*`, `observability.*` (read once at boot — restart the server to apply; cron / evolution runs pick a change up on their next run), `evolution.*`, `limits.*`. All `general.*` keys are `globalOnly` except `evolution.level` and `evolution.auto_apply_threshold`: `config.ts` resolves the `globalOnly` ones through `settingsValue()` against `~/.halo/secrets/settings.yaml` only, so a workspace `settings.yaml` cannot override them. Per-workspace layering applies to namespaced `params` / `secrets` (`getServerSecret(ns, key, workspaceRoot)`, `substituteSecrets`).
106
106
 
107
107
  `server.trust_proxy` (boolean, default `false`, `globalOnly`): whether the brute-force rate limiter (`middleware/brute-force.ts` `getClientIp`) trusts the `x-forwarded-for` header for client IP resolution. Direct-connect deployments leave it `false` and get the socket address. Behind a reverse proxy (nginx / Cloudflare / etc.), set it to `true` so the real client IP is honored instead of the proxy's — but only when that proxy is one you control and rewrites the header itself, otherwise a client can forge XFF to dodge lockouts.
108
108
 
109
109
  `sandbox.hidden_dirs` / `sandbox.hidden_files` / `sandbox.writable_dirs` are `globalOnly` — they define the security boundary agents run inside, so a workspace `settings.yaml` cannot override them (a workspace overriding them could lift its own sandbox constraints). `hidden_dirs` / `hidden_files` hold **extra** entries: the effective list is the built-in default (`DEFAULT_HIDDEN_DIRS` / `DEFAULT_HIDDEN_FILES` in `tools/sandbox.ts`, the single source — `~/.halo/secrets`, `~/.aws`, `~/.ssh`, `~/.gnupg`, `~/.docker`, `~/.config/gh`, `~/.halo/global/internal-sessions`, `~/.halo/global/logs`; `~/.npmrc`, `~/.bash_history`, `~/.gitconfig`, `~/.git-credentials`, `~/.netrc`, the global `evo` / `cron` / `runs` databases + `-wal` / `-shm`) plus the user's entries, deduped (`config.ts` `resolveSandboxPaths()`, used by both the server and the CLI). A default entry cannot be removed from the list in settings. The lists apply only to workspace / readonly sessions (bwrap / Seatbelt, or `assertPathAllowed` without an OS sandbox); Full sessions, the admin file explorer (`validatePath`, workspace-bound only) and the terminal never consult them. The schema `default` is empty so the placeholder doesn't suggest copying the built-ins; the field description names them. `writable_dirs` is a plain list (no built-ins).
110
110
 
111
- `evolution.*` controls the self-evolution subsystem (see [design/evolution.md](../design/evolution.md)). All `evolution.*` keys are `globalOnly` — they live in `~/.halo/secrets/settings.yaml` only, not workspace settings. Notable knobs: `evolution.level` (`L0` = manual only: `/evo` drafts, a reviewer approves; `L1` = L0 plus automatic drafting on pre-compact; `L2` = L1 plus auto-applying prompt-only changes that pass the gates; `L3` = L2 plus auto-applying capability changes — auto-applied changes still pass the regression check and can be rolled back from the Evolution tab; only changes learned from a `full`-access session auto-apply, never from one that took cron webhook events; a cron run's own turn never drafts on pre-compact), `evolution.auto_apply_threshold` (int 0–100, default 80 — minimum average score at L2/L3, on top of the fixed floors confidence high / behavior ≥ 70 / lint 100 / all sandbox checks; a save outside 0–100 is rejected with 400), `evolution.max_concurrent_run` / `max_concurrent_apply` (wrapper concurrency caps), `evolution.run_timeout_minutes` / `apply_timeout_minutes` (heartbeat timeouts), `evolution.max_attempts` (per-row retry cap), `evolution.triggers.pre_compact` (snapshot session before compaction).
111
+ `evolution.*` controls the self-evolution subsystem (see [design/evolution.md](../design/evolution.md)). `evolution.level` and `evolution.auto_apply_threshold` can be set **per workspace**: `<workspace>/.halo/settings.yaml` overrides `~/.halo/secrets/settings.yaml`, which overrides the schema default (an open workspace can sit at L0 while a private one auto-applies), and the workspace Settings page edits them like any non-`globalOnly` field. The rest of `evolution.*` is `globalOnly` — it lives in `~/.halo/secrets/settings.yaml` only, not workspace settings. Notable knobs: `evolution.level` (`L0` = manual only: `/evo` drafts, a reviewer approves; `L1` = L0 plus automatic drafting on pre-compact; `L2` = L1 plus auto-applying prompt-only changes that pass the gates; `L3` = L2 plus auto-applying capability changes — auto-applied changes still pass the regression check and can be rolled back from the Evolution tab; only changes learned from a `full`-access session auto-apply; a cron run's own turn never drafts on pre-compact), `evolution.auto_apply_threshold` (int 0–100, default 80 — minimum average score at L2/L3, on top of the fixed floors confidence high / behavior ≥ 70 / lint 100 / all sandbox checks; a save outside 0–100 is rejected with 400), `evolution.max_concurrent_run` / `max_concurrent_apply` (wrapper concurrency caps), `evolution.run_timeout_minutes` / `apply_timeout_minutes` (heartbeat timeouts), `evolution.max_attempts` (per-row retry cap), `evolution.triggers.pre_compact` (snapshot session before compaction).
112
112
 
113
113
  Goal mode's entry points are offline (the `/goal` command, admin banner and creation of new goal sessions are hidden). To reopen them, set `general.goal_mode_enabled` — a global-only boolean, default `false`, read from `~/.halo/secrets/settings.yaml` only (a workspace `settings.yaml` has no effect) and not shown in the Settings UI — then restart the Halo server and refresh the browser (existing goal bindings and history are not touched either way):
114
114
 
@@ -869,9 +869,12 @@ function generalSection() {
869
869
  displayName_zh: "\u5E38\u89C4",
870
870
  description: "Server behavior parameters (session limits, compaction, sandbox, logging).",
871
871
  description_zh: "\u670D\u52A1\u7AEF\u884C\u4E3A\u53C2\u6570\uFF08\u4F1A\u8BDD\u9650\u5236\u3001\u538B\u7F29\u3001\u6C99\u7BB1\u3001\u65E5\u5FD7\uFF09\u3002",
872
- // Every general.* field is globalOnly: config.ts reads them via
873
- // settingsValue() from the global settings.yaml only. Per-workspace
874
- // layering exists solely for namespaced params/secrets (getServerSecret).
872
+ // Every general.* field is globalOnly — config.ts reads them via
873
+ // settingsValue() from the global settings.yaml only; per-workspace
874
+ // layering is for namespaced params/secrets (getServerSecret) — except
875
+ // evolution.level and evolution.auto_apply_threshold: how far evo may act
876
+ // on its own is a property of the workspace (an open one at L0, a private
877
+ // one at L2/L3), read per workspace by evoLevelFor / autoApplyThresholdFor.
875
878
  fields: [
876
879
  // system-wide language preference. Read by internal agents (evo, score)
877
880
  // that don't load INSTRUCTIONS.md, and available to anything else that
@@ -917,11 +920,13 @@ function generalSection() {
917
920
  // a workspace must not widen; public_url names this server to peers.
918
921
  { key: "a2a.public_url", type: "string", globalOnly: true, description: "Origin peers use to reach this server for A2A (e.g. http://myhost.tailnet.ts.net:9527): the agent card's interface URL and the webhook URL given to remotes. Empty = derived from the request Host. Env HALO_A2A_PUBLIC_URL overrides.", description_zh: "\u5BF9\u7AEF\u8BBF\u95EE\u672C\u670D\u52A1\u7684 A2A \u6E90\u5730\u5740\uFF08\u5982 http://myhost.tailnet.ts.net:9527\uFF09\uFF1A\u7528\u4E8E agent card \u7684\u63A5\u53E3 URL \u548C\u4EA4\u7ED9\u8FDC\u7AEF\u7684 webhook URL\u3002\u7559\u7A7A\u5219\u6309\u8BF7\u6C42 Host \u63A8\u5BFC\u3002\u73AF\u5883\u53D8\u91CF HALO_A2A_PUBLIC_URL \u4F18\u5148\u3002", default: "" },
919
922
  { key: "a2a.url_allowlist", type: "string", globalOnly: true, description: "Comma-separated CIDRs / host patterns A2A may reach besides public https (push webhooks, remote cards and RPC). Private / loopback ranges are refused unless listed; plain http is allowed only to listed hosts / ranges. Link-local (169.254.0.0/16, fe80::/10) is always refused.", description_zh: "A2A \u9664\u516C\u7F51 https \u4E4B\u5916\u5141\u8BB8\u8BBF\u95EE\u7684 CIDR / \u4E3B\u673A\u6A21\u5F0F\uFF08\u9017\u53F7\u5206\u9694\uFF0C\u7528\u4E8E push webhook\u3001\u8FDC\u7AEF card \u548C RPC\uFF09\u3002\u79C1\u7F51 / \u56DE\u73AF\u5730\u5740\u5FC5\u987B\u5217\u51FA\u624D\u5141\u8BB8\uFF1B\u660E\u6587 http \u53EA\u5141\u8BB8\u5217\u51FA\u7684\u4E3B\u673A / \u7F51\u6BB5\u3002\u94FE\u8DEF\u672C\u5730\u5730\u5740\uFF08169.254.0.0/16\u3001fe80::/10\uFF09\u59CB\u7EC8\u62D2\u7EDD\u3002", default: DEFAULT_A2A_URL_ALLOWLIST },
920
- // self-evolution (see plans/self-evolution.md). All evo settings are
921
- // global-only — the worker / ticker live in the server process and
922
- // would have to reload mid-flight if a workspace could override them.
923
- { key: "evolution.level", type: "enum", options: ["L0", "L1", "L2", "L3"], globalOnly: true, description: "Self-evolution capability ladder. L0: manual only \u2014 drafts via /evo, manual approve/reject. L1: L0 plus automatic drafting on pre-compact. L2: L1 plus auto-applying prompt-only changes that score above evolution.auto_apply_threshold. L3: L2 plus auto-applying capability changes (agent.yaml tools / model, skill invocation). Auto-applied changes still pass the regression check and can be rolled back from the Evolution tab. Queued runs always execute regardless of level.", description_zh: "\u81EA\u6211\u8FDB\u5316\u7B49\u7EA7\u3002L0\uFF1A\u4EC5\u624B\u52A8\u2014\u2014\u901A\u8FC7 /evo \u8D77\u8349\uFF0C\u624B\u52A8 approve/reject\u3002L1\uFF1A\u5728 L0 \u57FA\u7840\u4E0A\uFF0C\u538B\u7F29\u524D\uFF08pre-compact\uFF09\u81EA\u52A8\u8D77\u8349\u3002L2\uFF1A\u5728 L1 \u57FA\u7840\u4E0A\uFF0C\u5BF9\u8BC4\u5206\u8D85\u8FC7 evolution.auto_apply_threshold \u7684\u7EAF\u63D0\u793A\u8BCD\u6539\u52A8\u81EA\u52A8\u5E94\u7528\u3002L3\uFF1A\u5728 L2 \u57FA\u7840\u4E0A\uFF0C\u5BF9\u80FD\u529B\u7C7B\u6539\u52A8\uFF08agent.yaml \u7684\u5DE5\u5177/\u6A21\u578B\u3001skill \u8C03\u7528\u65B9\u5F0F\uFF09\u4E5F\u81EA\u52A8\u5E94\u7528\u3002\u81EA\u52A8\u5E94\u7528\u7167\u6837\u8FC7\u56DE\u5F52\u68C0\u67E5\uFF0C\u53EF\u5728 Evolution \u9875\u56DE\u6EDA\u3002\u5DF2\u6392\u961F\u7684 run \u4E0D\u53D7\u7B49\u7EA7\u5F71\u54CD\uFF0C\u59CB\u7EC8\u6267\u884C\u3002", default: "L0" },
924
- { key: "evolution.auto_apply_threshold", type: "int", min: 0, max: 100, globalOnly: true, description: "Minimum average score (0\u2013100) for auto-apply at L2/L3. Also required: confidence high, behavior \u2265 70, lint 100, all sandbox checks passed. Ignored at L0/L1.", description_zh: "L2/L3 \u81EA\u52A8\u5E94\u7528\u8981\u6C42\u7684\u6700\u4F4E\u5E73\u5747\u5206\uFF080\u2013100\uFF09\u3002\u53E6\u9700\u540C\u65F6\u6EE1\u8DB3\uFF1Aconfidence \u4E3A high\u3001behavior \u2265 70\u3001lint \u4E3A 100\u3001\u6C99\u7BB1\u68C0\u67E5\u5168\u90E8\u901A\u8FC7\u3002L0/L1 \u4E0B\u5FFD\u7565\u3002", default: "80" },
923
+ // self-evolution (see plans/self-evolution.md). The level and the
924
+ // auto-apply threshold are per workspace (read where a run's workspace is
925
+ // known: the pre-compact trigger, the wrapper's auto-apply verdict and
926
+ // recheck). The rest stays global-only: the ticker's slots, timeouts and
927
+ // retries are server-wide, and pre-compact snapshots are a server switch.
928
+ { key: "evolution.level", type: "enum", options: ["L0", "L1", "L2", "L3"], description: "Self-evolution capability ladder. L0: manual only \u2014 drafts via /evo, manual approve/reject. L1: L0 plus automatic drafting on pre-compact. L2: L1 plus auto-applying prompt-only changes that score above evolution.auto_apply_threshold. L3: L2 plus auto-applying capability changes (agent.yaml tools / model, skill invocation). Auto-applied changes still pass the regression check and can be rolled back from the Evolution tab. Queued runs always execute regardless of level.", description_zh: "\u81EA\u6211\u8FDB\u5316\u7B49\u7EA7\u3002L0\uFF1A\u4EC5\u624B\u52A8\u2014\u2014\u901A\u8FC7 /evo \u8D77\u8349\uFF0C\u624B\u52A8 approve/reject\u3002L1\uFF1A\u5728 L0 \u57FA\u7840\u4E0A\uFF0C\u538B\u7F29\u524D\uFF08pre-compact\uFF09\u81EA\u52A8\u8D77\u8349\u3002L2\uFF1A\u5728 L1 \u57FA\u7840\u4E0A\uFF0C\u5BF9\u8BC4\u5206\u8D85\u8FC7 evolution.auto_apply_threshold \u7684\u7EAF\u63D0\u793A\u8BCD\u6539\u52A8\u81EA\u52A8\u5E94\u7528\u3002L3\uFF1A\u5728 L2 \u57FA\u7840\u4E0A\uFF0C\u5BF9\u80FD\u529B\u7C7B\u6539\u52A8\uFF08agent.yaml \u7684\u5DE5\u5177/\u6A21\u578B\u3001skill \u8C03\u7528\u65B9\u5F0F\uFF09\u4E5F\u81EA\u52A8\u5E94\u7528\u3002\u81EA\u52A8\u5E94\u7528\u7167\u6837\u8FC7\u56DE\u5F52\u68C0\u67E5\uFF0C\u53EF\u5728 Evolution \u9875\u56DE\u6EDA\u3002\u5DF2\u6392\u961F\u7684 run \u4E0D\u53D7\u7B49\u7EA7\u5F71\u54CD\uFF0C\u59CB\u7EC8\u6267\u884C\u3002", default: "L0" },
929
+ { key: "evolution.auto_apply_threshold", type: "int", min: 0, max: 100, description: "Minimum average score (0\u2013100) for auto-apply at L2/L3. Also required: confidence high, behavior \u2265 70, lint 100, all sandbox checks passed. Ignored at L0/L1.", description_zh: "L2/L3 \u81EA\u52A8\u5E94\u7528\u8981\u6C42\u7684\u6700\u4F4E\u5E73\u5747\u5206\uFF080\u2013100\uFF09\u3002\u53E6\u9700\u540C\u65F6\u6EE1\u8DB3\uFF1Aconfidence \u4E3A high\u3001behavior \u2265 70\u3001lint \u4E3A 100\u3001\u6C99\u7BB1\u68C0\u67E5\u5168\u90E8\u901A\u8FC7\u3002L0/L1 \u4E0B\u5FFD\u7565\u3002", default: "80" },
925
930
  { key: "evolution.max_concurrent_run", type: "int", globalOnly: true, description: "Max concurrent evo run wrappers (drafting patches).", description_zh: "\u540C\u65F6\u8FD0\u884C\u7684 evo run wrapper \u4E0A\u9650\uFF08\u8D77\u8349 patch\uFF09\u3002", default: "1" },
926
931
  { key: "evolution.max_concurrent_apply", type: "int", globalOnly: true, description: "Max concurrent evo apply wrappers (merging patches).", description_zh: "\u540C\u65F6\u8FD0\u884C\u7684 evo apply wrapper \u4E0A\u9650\uFF08\u5408\u5E76 patch\uFF09\u3002", default: "1" },
927
932
  { key: "evolution.run_timeout_minutes", type: "int", globalOnly: true, description: "Heartbeat timeout for run wrappers. The wrapper writes a heartbeat every 60s for as long as it lives, so exceeding this means the wrapper process died \u2014 the row is requeued (or marked timeout past max_attempts). This is dead-wrapper detection latency, NOT a budget for slow models; the per-phase model budget is the wrapper-internal cap (draft 60min, other phases 45min).", description_zh: "run wrapper \u5FC3\u8DF3\u8D85\u65F6\uFF08\u5206\u949F\uFF09\u3002wrapper \u5B58\u6D3B\u671F\u95F4\u6BCF 60 \u79D2\u5199\u4E00\u6B21\u5FC3\u8DF3\uFF0C\u8D85\u65F6\u5373\u89C6\u4E3A wrapper \u8FDB\u7A0B\u5DF2\u6B7B\uFF0C\u4EFB\u52A1\u91CD\u6392\uFF08\u8D85\u8FC7 max_attempts \u5219\u6807 timeout\uFF09\u3002\u8FD9\u662F\u6B7B wrapper \u7684\u68C0\u6D4B\u5EF6\u8FDF\uFF0C\u4E0D\u662F\u6162\u6A21\u578B\u7684\u8FD0\u884C\u9884\u7B97\u2014\u2014\u5355\u9636\u6BB5\u6A21\u578B\u9884\u7B97\u662F wrapper \u5185\u90E8\u7684\u4E0A\u9650\uFF08\u8D77\u8349 60 \u5206\u949F\uFF0C\u5176\u4F59\u9636\u6BB5 45 \u5206\u949F\uFF09\u3002", default: "12" },
@@ -1303,6 +1308,21 @@ function systemString(envKey, configPath, fallback) {
1303
1308
  return cv;
1304
1309
  return fallback;
1305
1310
  }
1311
+ var _wsSettingsCache = /* @__PURE__ */ new Map();
1312
+ function getWorkspaceSettings(workspaceRoot) {
1313
+ const p = path11.join(workspaceRoot, ".halo", "settings.yaml");
1314
+ let mtimeMs = 0;
1315
+ try {
1316
+ mtimeMs = fs8.statSync(p).mtimeMs;
1317
+ } catch {
1318
+ }
1319
+ const hit = _wsSettingsCache.get(workspaceRoot);
1320
+ if (hit && hit.mtimeMs === mtimeMs)
1321
+ return hit.data;
1322
+ const entry = { mtimeMs, data: loadYamlFile(p) };
1323
+ _wsSettingsCache.set(workspaceRoot, entry);
1324
+ return entry.data;
1325
+ }
1306
1326
  var EVO_LEVELS = ["L0", "L1", "L2", "L3"];
1307
1327
  function parseEvoLevel(raw) {
1308
1328
  return EVO_LEVELS.includes(raw) ? raw : "L0";
@@ -1310,6 +1330,19 @@ function parseEvoLevel(raw) {
1310
1330
  function evoLevelRank(level) {
1311
1331
  return EVO_LEVELS.indexOf(level);
1312
1332
  }
1333
+ function generalValueFor(workspaceRoot, dotPath) {
1334
+ const ws = readYamlValue(getWorkspaceSettings(workspaceRoot), dotPath);
1335
+ if (ws != null && String(ws) !== "")
1336
+ return String(ws);
1337
+ return settingsValue(dotPath);
1338
+ }
1339
+ function evoLevelFor(workspaceRoot) {
1340
+ return parseEvoLevel(generalValueFor(workspaceRoot, "general.evolution.level") ?? schemaDefault("general.evolution.level", "L0"));
1341
+ }
1342
+ function autoApplyThresholdFor(workspaceRoot) {
1343
+ const n = parseInt(generalValueFor(workspaceRoot, "general.evolution.auto_apply_threshold") ?? "", 10);
1344
+ return isNaN(n) ? settingsInt("general.evolution.auto_apply_threshold", 80) : n;
1345
+ }
1313
1346
  var config = {
1314
1347
  // Goal entry points are retired, not the runtime. Internal/global-only, no UI:
1315
1348
  // set general.goal_mode_enabled in ~/.halo/secrets/settings.yaml, then restart.
@@ -1498,16 +1531,10 @@ var config = {
1498
1531
  }
1499
1532
  },
1500
1533
  // Self-evolution (see plans/self-evolution.md). Getter-based so user edits
1501
- // in settings.yaml take effect on the next ticker run without restart.
1534
+ // in settings.yaml take effect on the next ticker run without restart. The
1535
+ // level and auto-apply threshold are per workspace: evoLevelFor /
1536
+ // autoApplyThresholdFor.
1502
1537
  evolution: {
1503
- get level() {
1504
- return parseEvoLevel(settingsStr("general.evolution.level", "L0"));
1505
- },
1506
- // Minimum average score for L2/L3 auto-apply (see evolution/auto-apply.ts
1507
- // for the extra fixed floors).
1508
- get autoApplyThreshold() {
1509
- return settingsInt("general.evolution.auto_apply_threshold", 80);
1510
- },
1511
1538
  get maxConcurrentRun() {
1512
1539
  return settingsInt("general.evolution.max_concurrent_run", 1);
1513
1540
  },
@@ -1877,11 +1904,8 @@ function categoryGate(level, cls) {
1877
1904
  return null;
1878
1905
  }
1879
1906
  function sourceGate(sourceAccess) {
1880
- if (sourceAccess === SOURCE_WEBHOOK)
1881
- return "source_webhook";
1882
1907
  return sourceAccess === "full" ? null : "source_access";
1883
1908
  }
1884
- var SOURCE_WEBHOOK = "webhook";
1885
1909
  function scoreGate(score, threshold) {
1886
1910
  if (!score)
1887
1911
  return "score_missing";
@@ -2824,7 +2848,7 @@ function markObservation(id, attempts, runDir, outcome, logFd) {
2824
2848
  `);
2825
2849
  }
2826
2850
  function maybeAutoApply(args) {
2827
- const level = config.evolution.level;
2851
+ const level = evoLevelFor(args.workspacePath);
2828
2852
  if (evoLevelRank(level) < 2)
2829
2853
  return false;
2830
2854
  let record;
@@ -2835,7 +2859,7 @@ function maybeAutoApply(args) {
2835
2859
  sandboxHalo: evoSandboxHaloDir(args.runDir),
2836
2860
  entries: PUBLISH_WHITELIST,
2837
2861
  level,
2838
- threshold: config.evolution.autoApplyThreshold,
2862
+ threshold: autoApplyThresholdFor(args.workspacePath),
2839
2863
  sourceAccess: args.sourceAccess,
2840
2864
  score: readScore(path13.join(args.runDir, "score.json")),
2841
2865
  observation: readObservation(path13.join(args.runDir, "patch.md"))
@@ -3217,7 +3241,7 @@ function autoApplyRecheck(workspacePath, applyDir, changed) {
3217
3241
  globalHalo: globalDir(),
3218
3242
  sandboxHalo: evoSandboxHaloDir(applyDir)
3219
3243
  }, changed));
3220
- return categoryGate(config.evolution.level, cls);
3244
+ return categoryGate(evoLevelFor(workspacePath), cls);
3221
3245
  }
3222
3246
  async function applyMode(id, logFd) {
3223
3247
  const row = loadApplyRow(id);