@turmind/halo 2.2.2 → 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.
@@ -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:
@@ -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)
@@ -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);