@turmind/halo 2.1.6 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/1607-bf227fc5e4db45b7.js +1 -0
  3. package/admin-out/_next/static/chunks/1989-dab5d5c24b8f4c9f.js +1 -0
  4. package/admin-out/_next/static/chunks/5287.7ea08918388fa804.js +1 -0
  5. package/admin-out/_next/static/chunks/app/page-cca81a2ed69100f5.js +1 -0
  6. package/admin-out/_next/static/chunks/{webpack-7f8156dbbdb54868.js → webpack-9532ec7f1fa9a757.js} +1 -1
  7. package/admin-out/_next/static/css/8fc43fce83e5ce5d.css +1 -0
  8. package/admin-out/index.html +1 -1
  9. package/admin-out/index.txt +5 -5
  10. package/bundled-docs/design/a2a.md +3 -3
  11. package/bundled-docs/design/agentcore.md +1 -1
  12. package/bundled-docs/design/architecture.md +3 -3
  13. package/bundled-docs/design/background-dispatch.md +1 -1
  14. package/bundled-docs/design/canvas-extensions.md +2 -2
  15. package/bundled-docs/design/cron.md +77 -53
  16. package/bundled-docs/design/evolution.md +111 -39
  17. package/bundled-docs/design/node.md +5 -12
  18. package/bundled-docs/design/observability.md +11 -11
  19. package/bundled-docs/design/prompt-system.md +1 -1
  20. package/bundled-docs/design/relay.md +2 -2
  21. package/bundled-docs/design/session.md +9 -7
  22. package/bundled-docs/design/slack.md +1 -1
  23. package/bundled-docs/design/storage.md +14 -11
  24. package/bundled-docs/design/telegram.md +1 -1
  25. package/bundled-docs/design/ws.md +3 -3
  26. package/bundled-docs/dev/api.md +16 -14
  27. package/bundled-docs/dev/env.md +2 -2
  28. package/bundled-docs/dev/logging.md +1 -1
  29. package/bundled-docs/dev/tools.md +2 -2
  30. package/bundled-docs/guide/cli.md +3 -3
  31. package/bundled-docs/guide/delegation-and-access.md +1 -1
  32. package/bundled-docs/guide/workspace.md +1 -1
  33. package/bundled-docs/requirements/command.md +1 -1
  34. package/bundled-docs/requirements/settings.md +2 -2
  35. package/bundled-docs/requirements/workspace.md +1 -1
  36. package/dist/evo-wrapper.js +913 -201
  37. package/dist/index.js +2626 -1762
  38. package/package.json +1 -1
  39. package/templates/agents/__apply_agent__/AGENT.md +10 -6
  40. package/templates/agents/__evo_agent__/AGENT.md +15 -4
  41. package/templates/agents/__score__/AGENT.md +4 -0
  42. package/templates/skills/cron/SKILL.md +37 -10
  43. package/templates/skills/cron/manage-cron.py +90 -15
  44. package/admin-out/_next/static/chunks/1101-d2f818895a3aa32e.js +0 -1
  45. package/admin-out/_next/static/chunks/1989-c24411a5e971b8b9.js +0 -1
  46. package/admin-out/_next/static/chunks/5058.8514aa6d6a947cce.js +0 -1
  47. package/admin-out/_next/static/chunks/app/page-84ab8d21c70823c0.js +0 -1
  48. package/admin-out/_next/static/css/6da86a077e119a6c.css +0 -1
  49. /package/admin-out/_next/static/{xg-EvYXc8NEUDwTlmbWvy → Rs9tIYSSITPLxv274DU9f}/_buildManifest.js +0 -0
  50. /package/admin-out/_next/static/{xg-EvYXc8NEUDwTlmbWvy → Rs9tIYSSITPLxv274DU9f}/_ssgManifest.js +0 -0
@@ -51,7 +51,7 @@ Slack-specific config JSON fields: `botToken`, `appToken`, `botUserId`, `teamId`
51
51
  The slack cron-dispatcher (`channels/slack/cron-dispatcher.ts`) registers itself with `cron/dispatcher.ts`'s registry at boot and **requires an explicit `chatId`** (format: `D0123` for a DM, `C0123` for a channel, `C0123:1700.0` for a thread):
52
52
 
53
53
  - Cron jobs created from inside a Slack chat via the `cron` skill auto-pin the current chat as `chatId`.
54
- - Admin-UI cron jobs that don't specify a target run silently — the result shows in the cron log, nothing is pushed.
54
+ - Admin-UI cron jobs that don't specify a target run silently — nothing is pushed; the result is in the run history and the job's session.
55
55
  - Slack has no "fan out to whitelist" model; every push needs an explicit destination.
56
56
 
57
57
  ## Modules
@@ -26,7 +26,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
26
26
  │ │ ├── halo.db # their agent_sessions rows — global so they don't pollute any workspace's halo.db
27
27
  │ │ └── <agentId>/<seg>.ui.jsonl … # same files as a workspace session (see Session files)
28
28
  │ ├── evo.db # Cross-workspace evolution queue (evolution_runs + evolution_applies)
29
- │ ├── cron.db # Cross-workspace cron jobs + run history (cron_jobs + cron_runs; schema in design/cron.md, migrations below)
29
+ │ ├── cron.db # Cross-workspace cron jobs + run history + webhook events (cron_jobs + cron_runs + cron_events; schema in design/cron.md, migrations below)
30
30
  │ ├── runs.db # Run ledger: (workspace, session_id) rows for server-driven runs in flight; steady state empty (running_sessions)
31
31
  │ ├── a2a.db # A2A: inbound tasks, push configs + outbox, outbound dispatches (see design/a2a.md)
32
32
  │ ├── extensions/<id>/ # Installed Canvas preview extensions (halo-extension.json + bundle)
@@ -36,7 +36,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
36
36
  │ │ └── silero-vad-v4/silero_vad.onnx
37
37
  │ ├── logs/ # Runtime logs (server.log)
38
38
  │ │ ├── evo/ # Per-evo-run wrapper logs
39
- │ │ └── cron/ # Per-cron-run cli stdout/stderr (30-day retention)
39
+ │ │ └── cron/ # Pre-2.2 cron cli-child stdout/stderr (in-process runs write none; 30-day retention)
40
40
  │ ├── server.lock # Single-instance pid lock
41
41
  │ └── .template-version # Template-seed marker (used by ensureHaloHome)
42
42
  ├── secrets/ # Sensitive files (not mounted into sandbox)
@@ -67,9 +67,11 @@ Defines the persisted-data format for every Halo surface. Format changes must re
67
67
  ├── evo/ # Self-evolution per-workspace artifacts
68
68
  │ ├── runs/<id>/ # per-evaluation: source-snapshot.json, tool-flow.md,
69
69
  │ │ # evo-context.json, sandbox/, patch.md, score.json,
70
- │ │ # sub-cli.log, optional .skip.md
70
+ │ │ # sub-cli.log, optional .skip.md, auto-apply.json (L2+ verdict)
71
71
  │ ├── applies/<id>/ # per-apply: meta.json, sandbox/, regress/<runId>/, apply.log
72
- │ ├── history/apply-<id>/ # pre-apply rollback snapshot (MANIFEST.json + the overwritten files)
72
+ │ ├── history/apply-<id>/ # pre-apply rollback snapshot: MANIFEST.json (per-file kind + sha256
73
+ │ │ # before/after + unchanged, createdDirs) + the overwritten files;
74
+ │ │ # after a rollback also rollback-pre/ (main just before) + ROLLBACK.json
73
75
  │ └── archive/ # zipped runs/applies past the retention window
74
76
  ├── tmp/ # Agent scratch files (logs, downloads, intermediate artifacts) — convention from TOOL_GUIDELINES
75
77
  ├── assets/<channel>/inbound/<accountId>/<date>/ # Inbound media per channel (image/voice/video/file)
@@ -439,16 +441,16 @@ A value of the form `<<ENV_NAME>>` is replaced at read time with `process.env.EN
439
441
  | `general.logging.level` | warn | `debug` / `info` / `warn` / `error`. Restart required |
440
442
  | `general.a2a.public_url` | `''` | Global-only. Origin peers use to reach this server for A2A (no trailing slash): the agent card's interface URL and the default push-webhook base for `a2a_send`. Empty = derived from the request host (no outbound dispatch without it or a remote's `push_base`). Env `HALO_A2A_PUBLIC_URL` overrides. See [a2a.md](a2a.md) |
441
443
  | `general.a2a.url_allowlist` | `100.64.0.0/10,*.ts.net` | Global-only. CIDRs / host patterns A2A egress may reach besides public https (push webhooks, remote cards and RPC, inbound image URLs). Private / loopback only when listed; plain http only to listed hosts / ranges; link-local always refused. See [a2a.md](a2a.md#push-delivery-and-url-policy) |
442
- | `general.observability.endpoint` | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`); empty = off. Restart required (server); cron / evolution runs use a new value on their next run. See [observability.md](observability.md) |
443
- | `general.observability.service_name` | `halo` | OTel resource `service.name`. Restart required (server); cron / evolution: next run |
444
- | `general.observability.headers` | `''` | **Secret.** Extra OTLP request headers, comma-separated `k=v`. Restart required (server); cron / evolution: next run |
445
- | `general.observability.capture_content` | `false` | Put prompt/completion/tool text on spans. Restart required (server); cron / evolution: next run |
444
+ | `general.observability.endpoint` | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`); empty = off. Restart required (server, cron runs included); evolution runs use a new value on their next run. See [observability.md](observability.md) |
445
+ | `general.observability.service_name` | `halo` | OTel resource `service.name`. Restart required (server, cron included); evolution: next run |
446
+ | `general.observability.headers` | `''` | **Secret.** Extra OTLP request headers, comma-separated `k=v`. Restart required (server, cron included); evolution: next run |
447
+ | `general.observability.capture_content` | `false` | Put prompt/completion/tool text on spans. Restart required (server, cron included); evolution: next run |
446
448
 
447
449
  Schema source: [packages/server/src/settings-schema.ts](../../../packages/server/src/settings-schema.ts) `generalSection()`.
448
450
 
449
451
  ### Caching
450
452
 
451
- `config.ts` reads settings.yaml lazily with mtime-watching: every read stats the file and reparses if mtime changed. UI saves bump mtime, so the server picks up new values on the next read **without a restart** — except the keys marked "Restart required" above plus `general.agent.max_retries`, which `config.ts` reads once into object literals at boot (observability: `initObservability()`, mapped onto `OTEL_*` env; the cron / evolution `halo cli` children don't inherit those settings-derived vars — `cleanChildEnv()` strips them — so each re-reads settings at its own start); their schema fields carry `restartRequired: true` and the Settings page shows a restart notice after saving one.
453
+ `config.ts` reads settings.yaml lazily with mtime-watching: every read stats the file and reparses if mtime changed. UI saves bump mtime, so the server picks up new values on the next read **without a restart** — except the keys marked "Restart required" above plus `general.agent.max_retries`, which `config.ts` reads once into object literals at boot (observability: `initObservability()`, mapped onto `OTEL_*` env; the evolution `halo cli` children don't inherit those settings-derived vars (cron runs in the server process and follows its boot-time values) — `cleanChildEnv()` strips them — so each re-reads settings at its own start); their schema fields carry `restartRequired: true` and the Settings page shows a restart notice after saving one.
452
454
 
453
455
  ### Orphans
454
456
 
@@ -707,9 +709,9 @@ Rows are never pruned.
707
709
  ### Schema change rules
708
710
 
709
711
  - `schema.sql` / each db's `CREATE_SQL` always describes the FULL current shape, so a fresh db is complete after the CREATEs alone
710
- - 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`); `channel-db.ts` / `evo-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
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
711
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
712
- - 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` (job folder → `halo cli --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). `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)
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)
713
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
714
716
  - Do not drop or rename existing columns
715
717
  - New channel types: add rows with a new `channel_type` value + define config shape in the channel's `accounts.ts` adapter
@@ -731,3 +733,4 @@ Rows are never pruned.
731
733
  | v1+ | 2026-10-13 | `CRON_MIGRATIONS` v6 (additive): `cron_runs.workspace_path`, backfilled from the job row (NULL where the job is already gone). |
732
734
  | v2 | 2026-10-15 | Session store 2.0: `{seg}.json` replaced by append-only `{seg}.ui.jsonl` (`add` / `patch` / `inflight` records) + `{seg}.raw.jsonl`; archive segments `{seg}.ui.arch.{N}.{h}.jsonl.gz`, committed count = segment files (no stored counter), archived at turn end over 5 MB; no metadata in files — `HALO_MIGRATIONS` v3 adds `agent_sessions.output` / `last_activity_at`, internal sessions get rows in `~/.halo/global/internal-sessions/halo.db`; `SessionMessage.compactSummary`. 1.x files are converted on first open (or `halo sessions migrate v1 v2`) and deleted after a read-back check. |
733
735
  | v2 | 2026-10-17 | AgentCore `/invocations` (additive): `agentcore_invocations` in the workspace `a2a.db`; `A2A_MIGRATIONS` v2 adds `result`, `result_files`, `superseded_by`. `reply_to` gains `{ invocation }`. Session-dirs mode lays out `<base>/{.template,<sid>}/`. |
736
+ | v2 | 2026-11-05 | Cron in-process (2.2, additive): `CRON_MIGRATIONS` v7 adds `cron_jobs.webhook_secret` + the `cron_events` table. `reply_to` gains `{ cron: runId }`. New runs write no `logs/cron/` file. |
@@ -65,7 +65,7 @@ Halo can send to a Telegram chat without that user having messaged the bot first
65
65
  The telegram cron-dispatcher (`channels/telegram/cron-dispatcher.ts`) registers itself with `cron/dispatcher.ts`'s registry at boot and **requires an explicit `chatId`** (numeric — Telegram private-chat ids equal user ids, group ids are negative):
66
66
 
67
67
  - Cron jobs created from inside a telegram chat via the `cron` skill auto-pin the current chat id (target `telegram:<account>:<chatId>`).
68
- - Admin-UI cron jobs that don't specify a target run silently — the result shows in the cron log, nothing is pushed.
68
+ - Admin-UI cron jobs that don't specify a target run silently — nothing is pushed; the result is in the run history and the job's session.
69
69
  - `@username` entries are not accepted by `sendMessage`; the dispatcher rejects non-numeric values with a clear error.
70
70
 
71
71
  Earlier drafts considered "fan-out to every id in `allowedUsers`" or "fall back to `lastActiveChatId`", but in practice both pushed the cron output to strangers' chats. The cron creator's intent is "reach me, the person who set this up" — so the only target is what the caller passes explicitly.
@@ -121,7 +121,7 @@ Sub-agent text is root-tree noise on this wire: the listener is the root's (sub
121
121
 
122
122
  `chat:system` producers (`session-manager.ts`'s `stop` event handling): a `max_tokens` stop emits `⚠️ [<agent>] Response truncated: output token limit reached.`; a `refusal` stop (Anthropic `stop_reason: "refusal"`, HTTP 200 — the model declined, not an error) emits `⚠️ [<agent>] Model declined to respond (<category>): <explanation> — …` suggesting `/session new` (see [session.md](session.md#resilient-execution-loop)).
123
123
 
124
- Server-internal flags on `AgentSessionEvent` that are **not** carried into the WS frame: `stream.final` (marks the turn's wrap-up text vs. pre-tool filler — consumed by channel responders and the cli, see [session.md](session.md#message-queue-and-drain)), `stream.streamed` / `thinking.streamed` (the text already went out as deltas — decides whether the whole event is forwarded at all). The admin renders every streamed block, so none is needed on the wire.
124
+ Server-internal flags on `AgentSessionEvent` that are **not** carried into the WS frame: `stream.final` (marks the turn's wrap-up text vs. pre-tool filler — consumed by channel responders and the cli, see [session.md](session.md#message-queue-and-drain)), `stream.streamed` / `thinking.streamed` (the text already went out as deltas — decides whether the whole event is forwarded at all), `system.outOfBand` (a `notifySystem` notice from outside the turn — the evo auto-apply line; still a plain `chat:system` here, only the cron run and `halo cli` leave it out of their printed text). The admin renders every streamed block, so none is needed on the wire.
125
125
 
126
126
  ### Other Server → Client messages
127
127
 
@@ -141,8 +141,8 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
141
141
  | `chat:stopped` | `chat:stop` / `chat:interrupt` handlers (this client only) | Stop/interrupt acknowledged — `{sessionId}` |
142
142
  | `session:compacted` | compact handler | Compaction complete — `{message?, contextTokens, sessionId}` |
143
143
  | `compact:started` / `compact:summarizing` / `compact:done` | compact handler; `ws/event-processor.ts` | Compaction progress, `{sessionId}`. An auto-compact whose LLM summary failed falls back to a local compact and closes with a `system` event carrying `compactEnd`, which also emits `compact:done` (the success path closes via `compacted`). |
144
- | `cron:job_changed` / `cron:run_changed` | `cron/runner.ts` + `routes/cron.ts` (broadcast) | Cron job/run state changed — the Cron tab re-fetches instead of polling. `cron:job_changed`: `{jobId, kind?}` (`created` / `updated` / `deleted` / `reconciled` / `missed`; a run's finalize sends `lastRunStatus` + `lastRunAt` instead), plus — whenever the job still exists, except `missed` — `workingDir` (workspace-relative, `null` = root), `stateless` (boolean) and `sessionAgentId` (agent of the job's existing session, `null` when stateless / not created / unreadable; the job's agent is locked to it). `cron:run_changed`: `{jobId, runId, status}`. See [cron.md](cron.md). |
145
- | `evolution:run_changed` / `evolution:apply_changed` | `evolution/ticker.ts` + `routes/evolution.ts` (broadcast) | Evolution run/apply state changed — drives the Evolution tab. See [evolution.md](evolution.md). |
144
+ | `cron:job_changed` / `cron:run_changed` | `cron/runner.ts` + `routes/cron.ts` (broadcast) | Cron job/run state changed — the Cron tab re-fetches instead of polling. `cron:job_changed`: `{jobId, kind?}` (`created` / `updated` / `deleted` / `reconciled` / `missed`; a run's finalize sends `lastRunStatus` + `lastRunAt` instead), plus — whenever the job still exists, except `missed` — `workingDir` (workspace-relative, `null` = root), `stateless` (boolean) and `sessionAgentId` (agent of the job's existing session, `null` when stateless / not created / unreadable; the job's agent is locked to it). `cron:run_changed`: `{jobId, runId, status}`; `status: 'deleted'` = that run row was withdrawn (a webhook fire's re-check found the session taken by another owner) — clients just re-fetch the list. See [cron.md](cron.md). |
145
+ | `evolution:run_changed` / `evolution:apply_changed` | `evolution/ticker.ts` + `routes/evolution.ts` (broadcast) | Evolution run/apply state changed — drives the Evolution tab. Includes the L2/L3 auto path (`running → approved` plus the new `approved_by='auto'` apply, pushed by the ticker's diff) and `rolled_back` (pushed by the rollback route). See [evolution.md](evolution.md). |
146
146
  | `models:changed` | `models/watcher.ts` `reloadAndBroadcast` (broadcast to all clients) | `~/.halo/global/models.d/` changed (hub provider yamls installed by `halo models install` / `/extension models`, or removed by hand) and the merged registry differs from the last one — the server has already dropped its registry cache. No payload: the admin bumps its models bus, and the Agents panel / agent form, the composer's image-capability check and the Settings page re-fetch `/agent-configs/models` / the settings schema. Also bumped on WS reconnect (a frame lost while down). Global, no workspace marker. See [storage.md](storage.md#model-registry-format). |
147
147
  | `skills:changed` | `ws/skills-changed.ts` `notifySkillsChanged` (REST `POST` / `DELETE /skills`, `PATCH /skills/:id/toggle`, `/skill disable` · `enable` · `delete`) · `ws/watcher-pool.ts` (the workspace watcher saw `.halo/skills/**` change) | A skill was created / deleted / toggled, or a workspace skill folder changed on disk (an agent's `file_write`, a hand edit). No payload: the admin bumps its skill bus, and the chat slash-command popup, the Skills page and the agent form's skill picker re-fetch. Scoped on the server: a workspace skill or a toggle (the disable flag lives in that workspace's db) goes via `broadcastToWorkspace`, a global skill via `broadcast`. Only real changes emit: a no-op mutation (409 / 404 / already disabled) sends nothing, and the watcher path compares a per-workspace key (folder ids + each SKILL.md's frontmatter), so body / resource edits and the echo of a mutation that already notified stay silent. The watcher debounce is 300 ms on top of the file watcher's own 300 ms flush. `~/.halo/global/skills/` has no watcher, so a hand edit there shows on the Skills page's window-focus refetch. Also bumped on WS reconnect while the Skills page is open. |
148
148
  | `extension:changed` | `extensions/watcher.ts` `rescanAndBroadcast` (broadcast to all clients) | `~/.halo/global/extensions/` changed (install / upgrade / uninstall / on-off switch, from the admin upload, the one-click update, the `extension` skill, or a manual `mv`/`rm`; a switch rewrites `.state.json`, which trips the same watcher) — carries the **full** `ExtensionsSnapshot` (`{extensions, disabled, errors}`: switched on, switched off, refused), not a diff, and only fires when the snapshot key differs from the last one. The admin replaces its extension list wholesale (a missing `disabled`, from an older server, reads as `[]`): the preview registry re-resolves open tabs from `extensions` only (an upgraded extension remounts, or shows a banner if the tab is dirty; an uninstalled or switched-off one falls back to the "no built-in preview" page, or keeps a dirty tab alive with a banner) and the Settings → Extensions panel re-renders. Global, no workspace marker — extensions are server-wide. See [canvas-extensions.md](canvas-extensions.md). |
@@ -227,11 +227,12 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
227
227
 
228
228
  | Method | Path | Purpose |
229
229
  |---|---|---|
230
- | GET | `/api/cron/jobs?limit=&before=` | Cursor-paginated newest-first by `createdAt`. Default `limit` 20, max 100; `before` is a `createdAt` cursor. Returns `{jobs, hasMore, nextCursor}`. Each job has `targets` decoded, a live `nextRunAt` (computed from schedule/runAt, inside the active window), `sessionId` (`null` = runs in the job's own session), `effectiveSessionId` (the session the cli actually runs in: `sessionId`, else the job's own — same id as the job, `cron-<id>` for a custom id without that prefix; older jobs were pinned to their original `cron-cron-…` session by a cron.db migration), `activeFrom` / `activeUntil` (`null` = unbounded), `workingDir` (workspace-relative folder the run focuses on; `null` = the workspace root), `stateless` (boolean; `true` = a fresh session every run), and `sessionAgentId` (the agent of the effective session once it exists — the job's agent is locked to it; `null` for a stateless job, a session not created yet, or a session that can't be read) |
231
- | POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt, schedule, runAt?, timezone?, timeoutSec?, sessionId?, workingDir?, stateless?, targets?, enabled?}`. Exactly one of `schedule` (5-field cron) or `runAt` (epoch ms, future) is required; mutually exclusive. `timeoutSec` must be an integer 60–21600 (else 400); unset = runner default 3600. `timezone` is an IANA name (e.g. `Asia/Shanghai`); unset / `null` / `''` = the host timezone (see `/api/cron/meta`); a non-string → 400 `timezone must be a string`, a zone `Intl.DateTimeFormat` rejects → 400 `invalid timezone: <value>`. `sessionId` picks an existing (or to-be-created) **root** session of `workspacePath` for the cli to run in — `[A-Za-z0-9_:-]{1,200}`, no `>` sub-session path (else 400); unset / `null` / `''` = the job's own session (id = the job id). A picked session that already exists keeps its own access level, and its agent must equal `agentId` (agent lock, below); see [design/cron.md](../design/cron.md#execution-runjob) for the shared-session guards. `activeFrom` / `activeUntil` (epoch ms or `null`, recurring only): the schedule fires only inside `[activeFrom, activeUntil)`; with `runAt` → 400; `activeUntil` must be in the future and after `activeFrom` (else 400); `activeFrom` may be in the past. `workingDir` (workspace-relative or absolute; unset / `null` / `''` = the root) must be an existing directory inside `workspacePath` (else 400 `workingDir: …`); stored workspace-relative. `stateless` (boolean, else 400) runs every fire in a fresh session; with `sessionId` → 400 `stateless (new session every run) and sessionId are mutually exclusive`. Agent lock: when the effective session already exists and runs another agent → 400 `session <id> already runs agent <x>; …`; a session db that can't be read → 400 `could not read session <id> from <ws>: …`. Returns `{ok, id}` |
232
- | PUT | `/api/cron/jobs/:id` | Partial update over the same fields. Re-validates `schedule` / `runAt` and enforces trigger-mode exclusivity on the **merged** row: both in one body → 400 `schedule and runAt are mutually exclusive`; supplying one side is a mode switch that implicitly clears the other; a body that clears the active side without supplying a replacement → 400 `schedule or runAt required`. `runAt` must be a finite future epoch-ms number or `null`. `timeoutSec` follows the same partial-body contract: omitted = unchanged, `null` = clear back to the default, a number is range-validated (60–21600) same as create. `timezone` likewise: omitted = unchanged, `null` / `''` = clear back to the host timezone, anything else is validated same as create (same two 400s). `sessionId` likewise: omitted = unchanged, `null` / `''` = back to the job's own session (an older job pinned to `cron-cron-…` then starts a fresh one), a string is validated same as create. `activeFrom` / `activeUntil` likewise (omitted = unchanged, `null` = clear); the window rules are checked on the **merged** row, so switching to `runAt` while a window is stored needs both bounds cleared in the same body, and a newly supplied `activeUntil` must be in the future (a stored past one doesn't block editing other fields). `workingDir` likewise (omitted = unchanged, `null` / `''` = back to the root); a stored folder is re-checked when `workspacePath` changes. `stateless` and the `sessionId` exclusivity are checked on the **merged** row. The agent-lock / read-error 400s (see POST) apply only when the body changes the stored agent, session, `stateless` or `workspacePath` — so an unrelated edit such as `{enabled:false}` always succeeds, even for a job whose session already runs another agent. Flips schedule/unschedule on `enabled`. Returns `{ok}` |
230
+ | GET | `/api/cron/jobs?limit=&before=` | Cursor-paginated newest-first by `createdAt`. Default `limit` 20, max 100; `before` is a `createdAt` cursor. Returns `{jobs, hasMore, nextCursor}`. Each job has `targets` decoded, a live `nextRunAt` (computed from schedule/runAt, inside the active window), `sessionId` (`null` = runs in the job's own session), `effectiveSessionId` (the session the run actually uses: `sessionId`, else the job's own — same id as the job, `cron-<id>` for a custom id without that prefix; older jobs were pinned to their original `cron-cron-…` session by a cron.db migration), `activeFrom` / `activeUntil` (`null` = unbounded), `workingDir` (workspace-relative folder the run focuses on; `null` = the workspace root), `stateless` (boolean; `true` = a fresh session every run), and `sessionAgentId` (the agent of the effective session once it exists — the job's agent is locked to it; `null` for a stateless job, a session not created yet, or a session that can't be read), `webhookSecret` (`null` unless a webhook job) and `webhookPath` (`/cron-hooks/<id>`, relative — prefix the public origin; `null` unless a webhook job) |
231
+ | POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt, schedule, runAt?, webhook?, timezone?, timeoutSec?, sessionId?, workingDir?, stateless?, targets?, enabled?}`. Exactly one of `schedule` (5-field cron), `runAt` (epoch ms, future) or `webhook: true` is required (none → 400 `schedule, runAt or webhook required`; two → 400 `… mutually exclusive`). `webhook` (boolean, else 400) makes it a webhook-triggered job: the server generates its secret; a webhook job takes no `sessionId` (400 `a webhook job runs in its own session …`) and no active window. `timeoutSec` must be an integer 60–21600 (else 400); unset = runner default 3600. `timezone` is an IANA name (e.g. `Asia/Shanghai`); unset / `null` / `''` = the host timezone (see `/api/cron/meta`); a non-string → 400 `timezone must be a string`, a zone `Intl.DateTimeFormat` rejects → 400 `invalid timezone: <value>`. `sessionId` picks an existing (or to-be-created) **root** session of `workspacePath` for the runs to use — `[A-Za-z0-9_:-]{1,200}`, no `>` sub-session path (else 400); unset / `null` / `''` = the job's own session (id = the job id). A picked session that already exists keeps its own access level, and its agent must equal `agentId` (agent lock, below); see [design/cron.md](../design/cron.md#execution-runjob) for the shared-session guards. `activeFrom` / `activeUntil` (epoch ms or `null`, recurring only): the schedule fires only inside `[activeFrom, activeUntil)`; with `runAt` → 400; `activeUntil` must be in the future and after `activeFrom` (else 400); `activeFrom` may be in the past. `workingDir` (workspace-relative or absolute; unset / `null` / `''` = the root) must be an existing directory inside `workspacePath` (else 400 `workingDir: …`); stored workspace-relative. `stateless` (boolean, else 400) runs every fire in a fresh session; with `sessionId` → 400 `stateless (new session every run) and sessionId are mutually exclusive`. Agent lock: when the effective session already exists and runs another agent → 400 `session <id> already runs agent <x>; …`; a session db that can't be read → 400 `could not read session <id> from <ws>: …`. Returns `{ok, id}` |
232
+ | PUT | `/api/cron/jobs/:id` | Partial update over the same fields. Re-validates `schedule` / `runAt` and enforces trigger-mode exclusivity on the **merged** row: both in one body → 400 `schedule and runAt are mutually exclusive`; supplying one side is a mode switch that implicitly clears the other; a body that clears the active side without supplying a replacement → 400 `schedule, runAt or webhook required`. `webhook: true` switches to webhook mode (an existing secret is kept; with `schedule` / `runAt` in the same body → 400); `webhook: false`, or a `schedule` / `runAt` alone, switches away and drops the secret. `runAt` must be a finite future epoch-ms number or `null`. `timeoutSec` follows the same partial-body contract: omitted = unchanged, `null` = clear back to the default, a number is range-validated (60–21600) same as create. `timezone` likewise: omitted = unchanged, `null` / `''` = clear back to the host timezone, anything else is validated same as create (same two 400s). `sessionId` likewise: omitted = unchanged, `null` / `''` = back to the job's own session (an older job pinned to `cron-cron-…` then starts a fresh one), a string is validated same as create. `activeFrom` / `activeUntil` likewise (omitted = unchanged, `null` = clear); the window rules are checked on the **merged** row, so switching to `runAt` while a window is stored needs both bounds cleared in the same body, and a newly supplied `activeUntil` must be in the future (a stored past one doesn't block editing other fields). `workingDir` likewise (omitted = unchanged, `null` / `''` = back to the root); a stored folder is re-checked when `workspacePath` changes. `stateless` and the `sessionId` exclusivity are checked on the **merged** row. The agent-lock / read-error 400s (see POST) apply only when the body changes the stored agent, session, `stateless` or `workspacePath` — so an unrelated edit such as `{enabled:false}` always succeeds, even for a job whose session already runs another agent. Flips schedule/unschedule on `enabled`. Returns `{ok}` |
233
233
  | DELETE | `/api/cron/jobs/:id` | Unschedule, delete each stateless run's own session (except running / user-continued ones) and the job's `cron_runs` rows, then delete the job. A run still in progress keeps its row and session until it finalizes, then both go ([cron.md → Retention](../design/cron.md#retention)). Returns `{ok}` |
234
- | POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget). Returns `{ok}` |
234
+ | POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget; on a webhook job the prompt runs alone, no events). Returns `{ok}` |
235
+ | POST | `/api/cron/jobs/:id/webhook-secret` | Rotate a webhook job's secret — the old one stops working at once. 404 unknown job, 400 `not a webhook job`. Returns `{ok, webhookSecret}` |
235
236
  | POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
236
237
 
237
238
  `targets` is `Array<{channelType, accountId, chatId?}>`. New job ids look like `cron-<base36-ts>-<rand>`.
@@ -240,8 +241,12 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
240
241
 
241
242
  | Method | Path | Purpose |
242
243
  |---|---|---|
243
- | GET | `/api/cron/jobs/:id/runs?limit=&before=` | Cursor-paginated newest-first by `cron_runs.id` (ISO-prefixed; sorts as text). Default `limit` 20, max 100. Returns `{runs, hasMore, nextCursor}`; each row has `dispatchResults` JSON-decoded and `sessionId` (the session the run used — a stateless job's per-run `cron-<id>-<base36 ms>`; `null` on runs from before the column, and on `skipped` rows) |
244
- | GET | `/api/cron/runs/:runId/log` | Read the raw log file for one run. Returns `{log}` (string or null when the file is missing / past retention) |
244
+ | GET | `/api/cron/jobs/:id/runs?limit=&before=` | Cursor-paginated newest-first by `cron_runs.id` (ISO-prefixed; sorts as text). Default `limit` 20, max 100. Returns `{runs, hasMore, nextCursor}`; each row has `dispatchResults` JSON-decoded and `sessionId` (the session the run used — a stateless job's per-run `cron-<id>-<base36 ms>`; `null` on runs from before the column, and on `skipped` rows) and `eventCount` (webhook events the run handled) |
245
+ | GET | `/api/cron/runs/:runId/log` | Read the raw log file of a pre-2.2 (cli-child) run. Returns `{log}` (string, or null for an in-process run — those write none, the session is the record — and when the file is missing / past retention) |
246
+
247
+ ### Webhook receiver
248
+
249
+ `POST /cron-hooks/:jobId` — outside `/api`, no admin cookie (`routes/cron-hooks.ts`; not mounted in AgentCore mode). Auth: GitHub `X-Hub-Signature-256: sha256=<HMAC-SHA256(secret, raw body)>` or `Authorization: Bearer <secret>`. Unknown job, not a webhook job and bad auth are one `404`. Body ≤ 1 MB (else 413), stored as received. GitHub `ping` → 200 `{ok, ping: true}` (even on a disabled job); disabled job → 409; 100 events already pending → 429. Delivery id = `X-GitHub-Delivery` / `Idempotency-Key` / `X-Request-Id`, else the body's sha256 — then the same body is a repeat for 10 minutes only (send a delivery id or `Idempotency-Key` for real dedupe); a repeat → 200 `{ok, duplicate: true, eventId}`, except a repeat of an event whose run failed → processed again, 202 `{ok, eventId, redelivered: true}`. Stored → 202 `{ok, eventId}`; the run starts (or the event joins the running one) without the caller waiting. Semantics: [cron.md → Webhook trigger](../design/cron.md#webhook-trigger).
245
250
 
246
251
  ### Reference data (for the create form)
247
252
 
@@ -354,10 +359,6 @@ File: `packages/server/src/nodes/routes.ts` (+ `hub.ts` for the WebSocket). Not
354
359
 
355
360
  `GET /api/nodes/connect` + WebSocket upgrade, headers `authorization: Bearer <token>`, `x-halo-node-protocol: 1`, `x-halo-node-instance: <uuid>`, `x-halo-node-info: <base64url JSON>`. Refusals before the upgrade: 429 locked out (5 bad tokens / 15 min, `node-token` bucket) · 426 protocol · 401 token · 404 workspace gone · 400 info / instance missing · 409 connected from another instance. Frames and close codes: [design/node.md](../design/node.md#protocol-v1).
356
361
 
357
- ### Internal relay
358
-
359
- `POST /api/nodes/call` — loopback only and header `x-halo-node-relay: <per-boot secret>` (else 403); in `PUBLIC_PATHS`, used by cron's `halo cli` child. Body `{workspace, op, node?, args?}` (`op: 'nodes'` = node_list) → `{ok: true, text}` or `{ok: false, error}`. Not a public API.
360
-
361
362
  ## A2A (Agent-to-Agent)
362
363
 
363
364
  Files: `packages/server/src/a2a/routes.ts` (JSON-RPC handler), `exposure.ts` (path resolution, auth, card), `outbound.ts` (push receiver). A2A v1.0, JSON-RPC binding only. Mounted in normal server mode outside `/api/*` (no admin cookie) and before the static frontend; not in the CLI / TUI. AgentCore mode serves the same handler at `/` instead — see [AgentCore runtime container](#agentcore-runtime-container-halo-agentcore-only). Design: [design/a2a.md](../design/a2a.md).
@@ -528,13 +529,14 @@ File: `packages/server/src/routes/evolution.ts`. Surfaces the global `evolution_
528
529
 
529
530
  | Method | Path | Purpose |
530
531
  |---|---|---|
531
- | GET | `/api/evolution/runs?archived=0\|1&limit=20&before=<createdAt>` | List evolution runs across workspaces, newest first, cursor-paginated (`limit` 1–300, `before` = previous page's `nextCursor`; `archived=1` lists archived runs instead). Returns `{runs, hasMore, nextCursor}`; each row carries the latest `applyId`/`applyStatus`. (Score isn't surfaced in the list — it's read from `score.json` on the detail fetch only.) |
532
- | GET | `/api/evolution/runs/:id` | Detail: db row + `patch.md` + `score.json` (parsed as-is — includes the scorer's `checks` object when present, see [design/evolution.md](../design/evolution.md#phase-c--score)) + `.skip.md` (when `status='skipped'`) + wrapper/sub-cli logs + a snapshot summary (first user message + first assistant reply + message count). Row carries `failureReason` (and `applyFailureReason` from the latest apply) — apply-side values include `phase A': apply cli exited <code>`, `phase A': apply agent aborted: <first line of ABORT.md>`, and `phase A': apply agent didn't produce apply.log`. |
533
- | POST | `/api/evolution/runs/:id/approve` `{reviewerHint?}` | Move run from `awaiting_review` → `approved`, insert a pending `evolution_applies` row that the ticker will pick up. Returns `{ok, applyId}`; 409 when the run isn't `awaiting_review`. |
532
+ | GET | `/api/evolution/runs?archived=0\|1&limit=20&before=<createdAt>` | List evolution runs across workspaces, newest first, cursor-paginated (`limit` 1–300, `before` = previous page's `nextCursor`; `archived=1` lists archived runs instead). Returns `{runs, hasMore, nextCursor}`; each row carries the latest `applyId`/`applyStatus`/`applyApprovedBy` (`reviewer` \| `auto`) and `hasObservation`. (Score isn't surfaced in the list — it's read from `score.json` on the detail fetch only.) |
533
+ | GET | `/api/evolution/runs/:id` | Detail: db row + `patch.md` + `score.json` (parsed as-is — includes the scorer's `checks` object when present, see [design/evolution.md](../design/evolution.md#phase-c--score)) + `.skip.md` (when `status='skipped'`) + wrapper/sub-cli logs + a snapshot summary (first user message + first assistant reply + message count). Row carries `failureReason` (and `applyFailureReason` from the latest apply) — apply-side values include `phase A': apply cli exited <code>`, `phase A': apply agent aborted: <first line of ABORT.md>`, and `phase A': apply agent didn't produce apply.log`, and for auto applies `auto_recheck:<reason>`. Also: `autoApply` (the L2+ verdict `auto-apply.json` — `level`, `threshold`, `sourceAccess`, `category`, `files[{rel, category, reason?}]`, `auto`, `reason`, `score`, `observation`; null below L2), `observation` (the draft's `## Larger observation`, or null), `rollback` (`{available, files[{rel, kind, state}]}` while the latest apply is `applied`, else null; states as in [Rollback](../design/evolution.md#rollback)) and `publishedFiles` (posix rels the latest apply effectively changed, for `applied` / `rolled_back`; else null). |
534
+ | POST | `/api/evolution/runs/:id/approve` `{reviewerHint?}` | Move run from `awaiting_review` → `approved` and insert a pending `evolution_applies` row (`approved_by='reviewer'`) in one transaction. Returns `{ok, applyId}`; 409 when the run isn't `awaiting_review` or already has an apply in flight / applied. (At L2/L3 the wrapper queues `approved_by='auto'` applies itself, without this route.) |
534
535
  | POST | `/api/evolution/runs/:id/reject` | Move run from `awaiting_review` → `rejected`; 409 otherwise. |
535
- | POST | `/api/evolution/runs/:id/retry` `{hint}` | Reset a finished run back to `pending` (attempts cleared) with the supplied hint (required, else 400) so the wrapper picks it up again. Rejected with 409 when the run is already `running` / `pending`. |
536
+ | POST | `/api/evolution/runs/:id/retry` `{hint}` | Reset a finished run back to `pending` (retry budget restarted: `attempts_base = attempts`; the `attempts` the list / detail return is `attempts - attempts_base`, this round's count) with the supplied hint (required, else 400) so the wrapper picks it up again. Rejected with 409 when the run is `running` / `pending` / `applied` (roll back first), or `approved` while its latest apply isn't `failed` / `timeout`. |
536
537
  | POST | `/api/evolution/runs/:id/hint` `{hint}` | Append text to `user_hint` (hint required, else 400) — memo only, doesn't change status. Returns `{ok, userHint}`. |
537
538
  | DELETE | `/api/evolution/runs/:id` | Delete a finished run: its on-disk artifacts (run dir + archive zip + wrapper log) **and** the DB row, plus every apply that references it (artifacts + row, `evolution:apply_changed` `kind:'deleted'`). Rejected with 409 for in-flight states (`pending` / `running` / `approved`) so a live wrapper / queued apply isn't pulled out from under. Broadcasts `evolution:run_changed` with `kind:'deleted'`. |
539
+ | POST | `/api/evolution/applies/:id/rollback` `{force?}` | Undo an `applied` apply from its history snapshot: restore overwritten files, remove new ones and the dirs the publish created (kept, and listed in `keptDirs`, when someone has since put files in them). `200 {ok, restored, removed, removedDirs, keptDirs}`; the apply and its source runs become `rolled_back`, broadcast on both channels. 409 `{error}` for `not_applied` / `apply_in_flight` / `no_snapshot`; `{error:'conflict', conflicts[{rel, reason: modified\|missing\|unverified}]}` unless `force`; `{error:'backup_mismatch', files}` even with `force`. Files already back at their pre-apply state are skipped. See [design/evolution.md](../design/evolution.md#rollback). |
538
540
  | GET | `/api/evolution/applies` | List in-flight apply rows (`pending` / `running` / `syncing`, newest 200; used for status badges). Returns `{applies}`. |
539
541
 
540
542
  ## AgentCore runtime container (`halo agentcore` only)
@@ -120,7 +120,7 @@ Source: `packages/server/src/config.ts`
120
120
  | `HALO_AGENTCORE_NOTIFY` | (none) | — | agentcore-a2a mode only: shell command (`sh -c`) run when a `POST /invocations` job finishes — stdin = the final reply, env + `HALO_SESSION_ID`, `HALO_RUN_STATUS` (`ok` \| `error`), `HALO_WORKSPACE` (also its cwd: the workspace; in session-dirs mode a root-only 0700 dir with the vetted MEDIA copies). Runs outside the agent sandbox, killed after 5 min (see [design/agentcore.md](../design/agentcore.md#completion-and-the-notify-hook)) |
121
121
  | `HALO_AGENTCORE_SESSION_DIRS` | (none) | — | `1` (agentcore-a2a mode, root): one workspace per AgentCore runtime session id — `HALO_WORKSPACE` is the base, `<base>/<id>` (seeded from `<base>/.template`) is bound at `/workspace` and served by an unprivileged worker that gets only an env allowlist (see [design/agentcore.md](../design/agentcore.md#session-dirs)) |
122
122
  | `HALO_DEFAULT_PROVIDER` | (none) | — | Read by `halo setup --non-interactive`: binds every built-in agent to this provider id (and its default model) and, for each of its env-backed keys whose variable is set, writes the `<<NAME>>` placeholder into `settings.yaml` (Bedrock excepted). An unknown id only prints a warning |
123
- | `HALO_CLI` | `halo` (`halo.cmd` on Windows) | — | `halo` cli executable that cron runs and the evolution wrapper spawn — a dev override |
123
+ | `HALO_CLI` | `halo` (`halo.cmd` on Windows) | — | `halo` cli executable the evolution wrapper spawns — a dev override (cron runs in-process since 2.2 and no longer uses it) |
124
124
  | `HALO_VERSION` | `dev` | — | Version string reported by `GET /api/health` and the cli; stamped by the bundle / desktop launcher, so normally not set by hand |
125
125
  | `HALO_NODE_TOKEN` | (none) | — | `halo node` only (on the linked machine): pairing token, used when `--token` isn't given — preferred over the flag, which shows up in `ps` / shell history. Stripped from child-process env (see [design/node.md](../design/node.md)) |
126
126
  | `HALO_PROMPT` | (none) | — | `plain` makes `halo setup` use numbered-menu prompts instead of the raw-mode TUI |
@@ -139,7 +139,7 @@ settings.yaml only (no env override):
139
139
  - `general.sandbox.writable_dirs` (default empty) — dirs writable inside the sandbox besides the workspace (Linux / macOS), for external CLIs that keep local state (e.g. `~/.kiro`); not applied to readonly sessions, scope: global
140
140
  - `general.sandbox.hidden_files` (default empty) — extra files hidden from workspace/readonly sessions, read as empty files (bwrap bind of `~/.halo/.sandbox-empty` on Linux, Seatbelt deny on macOS), **appended** to the built-in list, which is always included (`~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs,a2a}.db` + their `-wal`/`-shm` files); Full sessions, the admin file explorer and the terminal are not affected, scope: global
141
141
  - `general.logging.level` (default `warn`) — log level: debug | info | warn | error, restart required
142
- - `general.observability.endpoint` (default `''`) — OTLP collector base URL (e.g. `http://localhost:4318`); empty = off, scope: global, restart required (the server; `halo cli` / `halo tui` read all `general.observability.*` keys at start, so cron and evolution runs pick a change up on their next run). See [design/observability.md](../design/observability.md)
142
+ - `general.observability.endpoint` (default `''`) — OTLP collector base URL (e.g. `http://localhost:4318`); empty = off, scope: global, restart required (the server; `halo cli` / `halo tui` read all `general.observability.*` keys at start, so evolution runs pick a change up on their next run; cron runs in the server process and follows the server). See [design/observability.md](../design/observability.md)
143
143
  - `general.observability.service_name` (default `halo`) — OTel resource `service.name`, scope: global, restart required
144
144
  - `general.observability.headers` (default `''`, secret) — extra OTLP request headers, comma-separated `k=v`, scope: global, restart required
145
145
  - `general.observability.capture_content` (default `false`) — put prompt/completion/tool text on spans, scope: global, restart required; env `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` (`true` / `false` / `1` / `0`) overrides it
@@ -15,7 +15,7 @@ Any `?token=…` value in a message is replaced with `?token=<redacted>` before
15
15
 
16
16
  ## Log locations
17
17
 
18
- One file for the whole server: `~/.halo/global/logs/server.log`. A single server process serves many workspaces, so there is no per-workspace log. Cron runs and evolution runs keep their own logs under `~/.halo/global/logs/{cron,evo}/`. `halo server start -d` additionally captures the process's stdout/stderr in `~/.halo/logs/server.log` — via the systemd user unit's `StandardOutput=append:` on Linux with systemd, otherwise via the built-in supervisor, which adds its `[respawn]` lines — that is the file `halo server logs` tails. A system unit's stdout goes to the journal (`journalctl -u <unit>`).
18
+ One file for the whole server: `~/.halo/global/logs/server.log`. A single server process serves many workspaces, so there is no per-workspace log. Evolution runs keep their own logs under `~/.halo/global/logs/evo/`. Cron runs write none — a run's session and its run-history row are its record; `~/.halo/global/logs/cron/` only holds files of the older cli-child runner until they age out (30 days). `halo server start -d` additionally captures the process's stdout/stderr in `~/.halo/logs/server.log` — via the systemd user unit's `StandardOutput=append:` on Linux with systemd, otherwise via the built-in supervisor, which adds its `[respawn]` lines — that is the file `halo server logs` tails. A system unit's stdout goes to the journal (`journalctl -u <unit>`).
19
19
 
20
20
  ## Log format
21
21
 
@@ -449,7 +449,7 @@ A `bearer` remote's token is the settings secret `a2a.secrets.<remote>` (workspa
449
449
 
450
450
  **Enabling**: opt-in by the single name `a2a_send` in `agent.yaml`'s `tools:` — it brings `a2a_stop` / `a2a_read` / `a2a_list`; the other three names are not recognised on their own. Available at **every access level** (full, workspace, readonly, any channel) **except the A2A read-only profile** — an inbound A2A session at readonly never gets them, whatever its yaml lists, so a remote's readonly token can't make this server call third parties. The admin tool picker shows one `a2a_send` chip naming the set.
451
451
 
452
- **Server only**: in the CLI / TUI — and every cron run, which is a `halo cli` child — there is no push receiver; every A2A tool returns `{"code": 1, "error": "a2a is not available in the CLI / TUI — it only runs inside `halo server`, which receives the remote agent's push reports. This is permanent for this runtime, not a temporary outage: do not retry. …"}`. The tools are still listed there when the agent names `a2a_send`.
452
+ **Server only**: in the CLI / TUI there is no push receiver; every A2A tool returns `{"code": 1, "error": "a2a is not available in the CLI / TUI — it only runs inside `halo server`, which receives the remote agent's push reports. This is permanent for this runtime, not a temporary outage: do not retry. …"}`. The tools are still listed there when the agent names `a2a_send`.
453
453
 
454
454
  **Retries**: each request has a 30 s timeout. An AgentCore `-32054` "… please retry" answer is retried after 0.5 / 1 / 2 / 4 / 8 s, and a halo AgentCore container's lease-busy answer (`-32603` "workspace is in use by another runtime session …") after 5 / 10 / 15 / 20 s; then the last answer is returned as the error. Stopping the turn ends the wait at once — for the RPC and for the agent-card fetch before it.
455
455
 
@@ -547,7 +547,7 @@ Act on the user's **linked machines** — computers running `halo node`, paired
547
547
 
548
548
  **Paths**: a machine shares one folder (`--root`; `root` in `node_list`) — relative paths resolve against it, paths that resolve outside it (symlinks included) are refused: `"<p>" is outside this machine's shared folder (<root>) — node tools can only reach paths inside it`. A whole-machine node (`root: null, wholeMachine: true`) has no such check; relative paths resolve against its `base`.
549
549
 
550
- **Runtime**: in `halo server` calls go over the machine's WebSocket; cron runs (a `halo cli` child) reach it through the server's loopback relay. In the standalone CLI / TUI, the evo sandbox and AgentCore mode the tools are listed but every one — `node_list` included — returns `linked machines are not available in this runtime — only \`halo server\` accepts \`halo node\` connections. This is permanent for this runtime, not a temporary outage: do not retry. …`.
550
+ **Runtime**: in `halo server` (cron runs included) calls go over the machine's WebSocket. In the standalone CLI / TUI, the evo sandbox and AgentCore mode the tools are listed but every one — `node_list` included — returns `linked machines are not available in this runtime — only \`halo server\` accepts \`halo node\` connections. This is permanent for this runtime, not a temporary outage: do not retry. …`.
551
551
 
552
552
  **Errors** (returned as the tool error text):
553
553
 
@@ -215,9 +215,9 @@ Attach files or images to your message with `@file` and `@image`, or pull a dire
215
215
 
216
216
  ## OpenTelemetry export
217
217
 
218
- `halo cli` and `halo tui` export traces, metrics and logs the same way the server does: when the global `general.observability.endpoint` setting is non-empty, each run registers the OTLP exporters at start and the agent loop emits the same GenAI spans (`invoke_agent` / `chat` / `execute_tool`). With no endpoint nothing is loaded and startup is unaffected. The scheduled runs of [Cron Tasks](../design/cron.md) and the evolution pipeline are spawned `halo cli` children, so they show up in the collector too (`session.id` `cron-<jobId>` or the internal agent's name).
218
+ `halo cli` and `halo tui` export traces, metrics and logs the same way the server does: when the global `general.observability.endpoint` setting is non-empty, each run registers the OTLP exporters at start and the agent loop emits the same GenAI spans (`invoke_agent` / `chat` / `execute_tool`). With no endpoint nothing is loaded and startup is unaffected. The evolution pipeline's runs are spawned `halo cli` children, so they show up in the collector too (`session.id` = the internal agent's name); [Cron Tasks](../design/cron.md) run inside the server since 2.2 and export as the server.
219
219
 
220
- - **Telling processes apart**: they all share `service.name`; the `halo.process` resource attribute is `server`, `cli` or `tui`, and cron / evolution runs report `cli`.
220
+ - **Telling processes apart**: they all share `service.name`; the `halo.process` resource attribute is `server`, `cli` or `tui`, evolution runs report `cli`, cron runs `server`.
221
221
  - **Flushed on exit**: the batched telemetry is sent before the process ends — on normal completion, TUI quit, an error, and on SIGINT / SIGTERM (after the graceful stop, still exiting 130 / 143). The flush is capped at 3 s, so an unreachable collector delays the exit by at most that and never hangs it.
222
222
  - `halo agents` and `halo sessions` only list and don't export.
223
223
 
@@ -367,7 +367,7 @@ Session prefix: `cli_`. Sessions are persisted to `<workspace>/.halo/sessions/`
367
367
 
368
368
  `tui` / `cli` / `agents` / `sessions` refuse to run ("~/.halo/global/ not initialized. Run halo setup first.") until `halo setup` has seeded `~/.halo/global/`. Then the CLI replicates the server's init sequence (`initRuntime()` in `harness.ts`):
369
369
  1. `refreshTemplatesIfOutdated()` — the server's template startup check: when `~/.halo/global/.template-version` is behind the bundled `TEMPLATE_VERSION` (typically right after `halo upgrade`), re-seed `~/.halo/global/` before anything reads the models registry; logs `[CLI] Templates outdated (vX → vY), refreshing ~/.halo/global/` to stderr. Runs once per upgrade — afterwards the stamp matches and it's a single file read
370
- 2. `initObservability()` — `tui` / `cli` only (the list-only `agents` / `sessions` skip it): when `general.observability.endpoint` is set, register OTel providers so this run's traces, metrics and logs reach the collector, tagged `halo.process=cli` or `tui`; a no-op otherwise. Cron jobs and evolution runs are spawned `halo cli` children, so they export too. See [OpenTelemetry export](#opentelemetry-export)
370
+ 2. `initObservability()` — `tui` / `cli` only (the list-only `agents` / `sessions` skip it): when `general.observability.endpoint` is set, register OTel providers so this run's traces, metrics and logs reach the collector, tagged `halo.process=cli` or `tui`; a no-op otherwise. Evolution runs are spawned `halo cli` children, so they export too. See [OpenTelemetry export](#opentelemetry-export)
371
371
  3. `initLogger()` — redirect console to stderr + file logger
372
372
  4. `initBwrapCheck()` — probe sandbox availability
373
373
  5. `setSandboxHiddenPaths()` — configure sandbox paths
@@ -114,7 +114,7 @@ Extra rules:
114
114
  | Channel account (Web / Telegram / Slack / Feishu / WeCom / WeChat, and the ACP adapter via its Web token) | The account's `accessLevel`: `full` → full, `workspace` → workspace, `readonly` and `observer` → readonly. New accounts default to **readonly**. | Re-applied on every inbound message. Changing an account's level takes effect on the next message. |
115
115
  | Channel `/session new` | The account's level | — |
116
116
  | `start_session` (sub-agent) | **Parent's level at creation time** | Kept. A later change on the parent doesn't reach existing children; start a new child to get the new level. |
117
- | Cron job | Full (the `halo cli` default) | Keeps its stored level (passed through as `--access`) |
117
+ | Cron job | Full | Keeps its stored level (the run passes none) |
118
118
  | `halo cli` / TUI | `--access`, default `full` | `--access` value (a different level is persisted) |
119
119
  | `relay_send` into another workspace | Full | Keeps its stored level |
120
120
  | A2A inbound (`/a2a/<path>`, a web-channel token bound to that workspace) | The token's `accessLevel`, mapped like a channel account. A `readonly` / `observer` token gets the fixed A2A read-only profile: `file_read` / `view_image` / `file_list` / `grep` / `glob` + `continue_task`, even with an OS sandbox — no shell, write or fetch, no delegation, no relay / a2a tools | Re-applied on every inbound message |
@@ -127,7 +127,7 @@ Common settings:
127
127
  - `general.session.max_nesting_depth` — max session nesting depth
128
128
  - `general.compact.keep_messages` — recent messages kept intact during compaction
129
129
  - `general.logging.level` — `debug` / `info` / `warn` / `error`
130
- - `general.observability.endpoint` — OTLP collector URL; leave empty to keep observability off. The server and every `halo cli` / `halo tui` run (cron and evolution runs included) export to it (see [design/observability.md](../design/observability.md))
130
+ - `general.observability.endpoint` — OTLP collector URL; leave empty to keep observability off. The server (cron runs included) and every `halo cli` / `halo tui` run (evolution runs included) export to it (see [design/observability.md](../design/observability.md))
131
131
  - `<provider-id>.secrets.api_key` — provider credentials (Kimi / DeepSeek bearer token)
132
132
  - `<provider-id>.secrets.access_key_id` / `.secret_access_key` — AWS Bedrock
133
133
  - `<skill-id>.params.<key>` — values an agent can inject into its own `shell_exec` via `{{<skill-id>.params.<key>}}`
@@ -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). 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). 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
 
@@ -108,7 +108,7 @@ Declared in [packages/server/src/settings-schema.ts](../../../packages/server/sr
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), `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)). 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).
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
 
@@ -123,7 +123,7 @@ There is no "default provider" setting: a newly created agent's `model:` is a co
123
123
 
124
124
  **Advanced fold**: on the General page, `language` and `theme` are shown in the open; every other General field is in an "Advanced" area, collapsed by default (schema flag `advanced: true`, set in `generalSection()`).
125
125
 
126
- **Restart-required fields**: `agent.max_retries`, `session.max_queue_size`, `session.max_nesting_depth`, `compact.keep_messages`, `compact.max_summary_input`, `compact.max_message_slice`, `logging.level` and the four `observability.*` keys are read once at server start (object literals in `config.ts`; observability is mapped onto `OTEL_*` env by `initObservability()` at boot). For observability that applies to the server's own exporter only: cron / evolution runs are `halo cli` children that don't inherit those settings-derived `OTEL_*` vars (`cleanChildEnv()` strips them), so they use a new value on their next run — as the field descriptions say. They carry `restartRequired: true`; after a save or Reset of one of them the Settings page shows a notice above the section listing the saved keys and saying the Halo server must be restarted (dismissable; it stays until dismissed). Every other General key is read live.
126
+ **Restart-required fields**: `agent.max_retries`, `session.max_queue_size`, `session.max_nesting_depth`, `compact.keep_messages`, `compact.max_summary_input`, `compact.max_message_slice`, `logging.level` and the four `observability.*` keys are read once at server start (object literals in `config.ts`; observability is mapped onto `OTEL_*` env by `initObservability()` at boot). For observability that applies to the server's own exporter (cron runs included — they run in the server process) only: evolution runs are `halo cli` children that don't inherit those settings-derived `OTEL_*` vars (`cleanChildEnv()` strips them), so they use a new value on their next run — as the field descriptions say. They carry `restartRequired: true`; after a save or Reset of one of them the Settings page shows a notice above the section listing the saved keys and saying the Halo server must be restarted (dismissable; it stays until dismissed). Every other General key is read live.
127
127
 
128
128
  ### Field attributes
129
129
 
@@ -36,7 +36,7 @@ The Bottom Panel (Chat + Terminal) can also be **floated** into a draggable wind
36
36
  | Bot | Agents | — | Agent config editor + Test (full width) | — |
37
37
  | Zap | Skills | Skill list | SkillsMain (full height) | — |
38
38
  | MessageCircle | Channels | Channel list | Channel config editor | — |
39
- | Sparkles | Evolution | Run list + status filters | Evolution main (run/apply review) | — |
39
+ | Sparkles | Evolution | Run list + status / auto / observation filters | Evolution main (run/apply review, auto-apply verdict, rollback, observation → GitHub issue) | — |
40
40
  | Clock | Cron | Job list | Cron job detail / form + run audit | — |
41
41
  | Settings2 | Settings | — | Settings panel | — |
42
42