@turmind/halo 1.3.4 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/989-6a0ec35b8ae2cb39.js +1 -0
- package/admin-out/_next/static/chunks/app/page-bf9c0b21ae431c92.js +1 -0
- package/admin-out/_next/static/css/c0ea31cd787f7aa7.css +3 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/agent.md +16 -11
- package/bundled-docs/design/cron.md +19 -11
- package/bundled-docs/design/express-self.md +1 -1
- package/bundled-docs/design/prompt-system.md +1 -1
- package/bundled-docs/design/storage.md +7 -3
- package/bundled-docs/design/wechat.md +1 -1
- package/bundled-docs/design/ws.md +2 -1
- package/bundled-docs/dev/add-model-provider.md +1 -1
- package/bundled-docs/dev/api.md +6 -6
- package/bundled-docs/dev/deploy.md +1 -1
- package/bundled-docs/dev/desktop-packaging.md +1 -1
- package/bundled-docs/dev/env.md +4 -4
- package/bundled-docs/dev/tools.md +41 -22
- package/bundled-docs/guide/README.md +1 -0
- package/bundled-docs/guide/agents.md +2 -0
- package/bundled-docs/guide/chat.md +10 -0
- package/bundled-docs/guide/cli.md +8 -3
- package/bundled-docs/guide/delegation-and-access.md +161 -0
- package/bundled-docs/guide/sessions.md +1 -1
- package/bundled-docs/requirements/chat.md +8 -0
- package/dist/index.js +873 -465
- package/package.json +1 -1
- package/templates/agents/__apply_agent__/agent.yaml +1 -1
- package/templates/agents/__evo_agent__/agent.yaml +1 -1
- package/templates/agents/__score__/agent.yaml +1 -1
- package/templates/agents/deep-executor/agent.yaml +1 -1
- package/templates/agents/default/agent.yaml +1 -1
- package/templates/agents/goal/agent.yaml +1 -1
- package/templates/models/mimo-token-plan-china.yaml +13 -6
- package/templates/skills/agent/SKILL.md +2 -2
- package/templates/skills/cron/SKILL.md +15 -0
- package/templates/skills/cron/manage-cron.py +28 -3
- package/templates/skills/halo/SKILL.md +2 -1
- package/templates/skills/send-file/SKILL.md +1 -0
- package/admin-out/_next/static/chunks/989-e660d0dd799950f2.js +0 -1
- package/admin-out/_next/static/chunks/app/page-18ab83dbdcc3897c.js +0 -1
- package/admin-out/_next/static/css/59402ac06b2b1a10.css +0 -3
- /package/admin-out/_next/static/{D2iJsfaULMDk0HYCHji6W → 7i420NUxbiDfuLxuofd-l}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{D2iJsfaULMDk0HYCHji6W → 7i420NUxbiDfuLxuofd-l}/_ssgManifest.js +0 -0
|
@@ -78,6 +78,7 @@ Optional `chat` fields:
|
|
|
78
78
|
- `images`: `Array<{data: base64, mimeType}>` — multimodal
|
|
79
79
|
- `agentId`: specify the agent this session should use
|
|
80
80
|
- `clientMsgId`: client-generated id for the ack/resend/dedup protocol (admin always sends it)
|
|
81
|
+
- `accessLevel`: `'full' | 'workspace' | 'readonly'` — the level picked in the admin input-box selector. Applied only on `handleChat`'s idle path (a message queued behind a running / compacting turn runs at the level that turn was built with); `'full'`, or any value on a host with no OS sandbox (`getSandboxBackend() === null`), maps to `null`. Passed to `sendUserMessage`, which rebuilds the agent and writes `agent_sessions.access_level` when it differs from the session's current level. Omitted = leave the session's level unchanged
|
|
81
82
|
|
|
82
83
|
## Server → Client
|
|
83
84
|
|
|
@@ -111,7 +112,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
|
|
|
111
112
|
|
|
112
113
|
| Type | Source | Purpose |
|
|
113
114
|
|---|---|---|
|
|
114
|
-
| `state:snapshot` | handler.ts on connect | Initial state (agents, messages, sessionId). On **subscribe / reattach only** it also carries `archiveCount` — the number of committed UI-log archive segments, which the admin's scroll-up loader counts down from (see [Archive anchor](#archive-anchor-in-statesnapshot)) |
|
|
115
|
+
| `state:snapshot` | handler.ts on connect | Initial state (agents, messages, sessionId). On **subscribe / reattach only** it also carries `archiveCount` — the number of committed UI-log archive segments, which the admin's scroll-up loader counts down from (see [Archive anchor](#archive-anchor-in-statesnapshot)) — and `accessLevel` (`'workspace' | 'readonly' | null`, null = full), which seeds the admin's access-level selector. Subscribe includes `accessLevel` only when the session already exists; for a not-yet-created session it's omitted, and the admin leaves the selector alone when the field is absent (so a level picked before the first send isn't reset) |
|
|
115
116
|
| `chat:ack` | `handleChat` | Chat with `clientMsgId` is persisted in the session log — releases the client's pending-ack entry (see [Chat delivery](#chat-delivery-ack--resend--dedup)) |
|
|
116
117
|
| `__pong__` | `__ping__` handler | Reply to the client's application-level liveness probe |
|
|
117
118
|
| `listener:released` | `reclaimIfAbandoned` (this client only) | This connection's event listener was reclaimed (silent >3 min / CLOSED) — `{sessionId}`. Client must re-`subscribe` to reattach. See [Abandoned-listener reclaim](#abandoned-listener-reclaim-and-the-__ping__-contract). |
|
|
@@ -27,7 +27,7 @@ Total effort: typically a few hours depending on how weird the upstream API is a
|
|
|
27
27
|
| `doubao` | `DoubaoAgent` | `doubao-agent.ts` | ByteDance Doubao Seed-2.0 (pro/lite/mini/code) on Volcengine Ark, OpenAI-compatible + `thinking:{type:'enabled'/'disabled'}` (no `auto`), automatic prompt caching, no vision |
|
|
28
28
|
| `openai` | `OpenAIAgent` | `openai-agent.ts` | **Generic OpenAI-compatible**. Use for native OpenAI or any third-party OpenAI gateway (incl. Ollama / llama.cpp). User fills endpoint + model id; capabilities openly editable in the form. Forgiving about cache field names (`prompt_tokens_details.cached_tokens`, `prompt_cache_hit_tokens`, `cache_read_tokens` all read) and reasoning field names (`reasoning_content` for OpenAI o-series/DeepSeek, `reasoning` for Ollama/llama.cpp). |
|
|
29
29
|
| `anthropic` | `AnthropicAgent` | `anthropic-agent.ts` | **Generic Anthropic Messages**. Use for api.anthropic.com or any Anthropic-compatible gateway. User fills endpoint + model id. Standard `cache_control: ephemeral` with optional `ttl: '1h'`. |
|
|
30
|
-
| `mimo-token-plan-china` | `AnthropicAgent` | (reuses `anthropic-agent.ts`) | Xiaomi MiMo
|
|
30
|
+
| `mimo-token-plan-china` | `AnthropicAgent` | (reuses `anthropic-agent.ts`) | Xiaomi MiMo (UI name "Xiaomi MiMo"; the id keeps its historical name so existing configs don't break) — Anthropic-compatible gateway; no dedicated class, just a default endpoint in `model-runtime.ts` (`token-plan-cn.xiaomimimo.com/anthropic`). The key type picks the endpoint: Token Plan `tp-`/`ttp-` keys only work on `token-plan-{cn,sgp,ams}.xiaomimimo.com/anthropic`, pay-as-you-go `sk-` keys only on `api.xiaomimimo.com/anthropic` — all four ship as `endpointPresets` (pay-as-you-go live-probed 2026-09-24; sgp / ams from the docs only). V2.6 (`mimo-v2.6-pro` / `-flash`, both vision, 1M / 131072) is current; `mimo-v2.5(-pro)` kept in the template until the official 2026-10-21 deprecation, then drop |
|
|
31
31
|
| `aws-bedrock-mantle` | `MantleAgent` | `mantle-agent.ts` | OpenAI GPT-6 (Astra / Sol / Luna) and GPT-5.6 (Sol / Terra / Luna) on Amazon Bedrock via the **OpenAI Responses API** (not Completions/Converse/InvokeModel). Vision, reasoning via `reasoning.effort` (low/med/high/xhigh/max, no budget_tokens; on 5.6 `max` is accepted live but absent from the model's own validation list — measured ~6% more reasoning_tokens than xhigh; on Astra it's on the official list), `text.verbosity` (low/medium/high, same on both generations). **Region split** (live-probed 2026-09-24): us-east-1 serves GPT-6 Sol / Luna + every 5.6 model (404s Astra); us-east-2 serves only the 5.6 trio; us-west-2 serves Astra + 5.6 Terra / Luna. Template ships all three as `endpointPresets`, default is now us-east-1 (`openai.gpt-6-sol` default model); Astra agents pick us-west-2 in the form; `extractRegionFromHost` falls back to us-east-1. All GPT-6 variants: 1,050,000 context / 128,000 max output, same effort (none/low/medium/high/xhigh/max) + verbosity surface; input beyond 272K tokens bills at 2x, so cap `context.maxTokens` in agent.yaml unless that's intended. Never surfaces reasoning summaries — `output[].reasoning.summary` stays empty even with `reasoning.summary=detailed` while `reasoning_tokens` counts up (verified live), so no thinking text in the UI is upstream behavior, not a parsing gap. |
|
|
32
32
|
| `aws-bedrock-openai` | `MantleAgent` | (reuses `mantle-agent.ts`) | Frontier models on Bedrock via the **bedrock-runtime** host's OpenAI Responses surface (`https://bedrock-runtime.<region>.amazonaws.com/openai/v1` — AWS's recommended endpoint for new apps; Mantle is the compatibility one). Same wire format, so only a second `case` in `model-runtime.ts` + `extractRegionFromHost` accepting both hostnames (SigV4 signs with the host's region — a mismatched region 401s "Credential should be scoped to a valid region"). Rules that differ from Mantle: model ids must be cross-Region inference profiles (`global.*` / `us.*`; bare ids 400 "on-demand throughput isn't supported"); **no region split** — live-probed 2026-09-24, all eight `global.*` ids answer 200 from us-east-1 / us-west-2 / eu-west-1 / ap-northeast-1 alike; **no server-side tools** — `tools:[{type:"web_search"}]` 400s "web search is not supported for this request", which is why the web-search skill's deep gear stays on Mantle. Ships the same six **GPT-6 / 5.6** entries as the Mantle template (`global.openai.gpt-6-{astra,sol,luna}`, `global.openai.gpt-5.6-{sol,terra,luna}`; identical effort none/low/medium/high/xhigh/max + verbosity surface per each model's own validation error — except Astra 400s on `none`, which the runtime never sends), plus **Grok 4.6** (500K ctx, 2x input price past 200K, max output undisclosed → 64000 carry-over, effort **only** low/medium/high/xhigh — `none`/`max` 400, so no Max preset; vision OK; $2/$6) and **Kimi K3** (1M ctx / 131072 out, effort none…max all accepted, ladder advisory on short prompts; vision OK; $3/$15). All: implicit prompt caching verified (run 1 `cache_write_tokens`, runs 2+ `cached_tokens` ≈ prefix), no reasoning summaries. |
|
|
33
33
|
|
package/bundled-docs/dev/api.md
CHANGED
|
@@ -12,7 +12,7 @@ Compression: every response negotiates gzip/deflate via `hono/compress` (`thresh
|
|
|
12
12
|
|
|
13
13
|
| Method | Path | Purpose |
|
|
14
14
|
|---|---|---|
|
|
15
|
-
| GET | `/api/health` | Health check — returns `{status, timestamp, uptime, engine, version, gitSha}` (`gitSha`: short sha on source builds, `-dirty` suffix if tree modified, `null` on published bundles) |
|
|
15
|
+
| GET | `/api/health` | Health check — returns `{status, timestamp, uptime, engine, version, gitSha, sandbox}` (`gitSha`: short sha on source builds, `-dirty` suffix if tree modified, `null` on published bundles; `sandbox`: OS sandbox backing non-full access levels — `'bwrap'` / `'seatbelt'` / `null`; the admin locks its access-level selector to Full on `null`) |
|
|
16
16
|
|
|
17
17
|
## Authentication
|
|
18
18
|
|
|
@@ -86,7 +86,7 @@ Unified session log API — list + read session files across all agents.
|
|
|
86
86
|
|
|
87
87
|
| Method | Path | Purpose |
|
|
88
88
|
|---|---|---|
|
|
89
|
-
| GET | `/api/sessions/logs?projectId=` | List session metadata, keyset-paginated. Default returns each top-level row + all descendants (sidebar tree); `rootOnly=1` returns roots only (chat-header dropdown) |
|
|
89
|
+
| GET | `/api/sessions/logs?projectId=` | List session metadata, keyset-paginated. Default returns each top-level row + all descendants (sidebar tree); `rootOnly=1` returns roots only (chat-header dropdown). 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 |
|
|
90
90
|
| GET | `/api/sessions/logs/:id?projectId=` | Full session log (scans across agent dirs) — the **active** file only; archived history is a separate call |
|
|
91
91
|
| GET | `/api/sessions/logs/:id/archive/:n?projectId=` | One archived UI-log segment — see [detail](#get-apisessionslogsidarchivenprojectidabs) |
|
|
92
92
|
| DELETE | `/api/sessions/logs/:id?projectId=` | Delete the session log (and all of its archive segments) |
|
|
@@ -184,9 +184,9 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
|
|
|
184
184
|
|
|
185
185
|
| Method | Path | Purpose |
|
|
186
186
|
|---|---|---|
|
|
187
|
-
| 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
|
|
188
|
-
| POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt, schedule, runAt?, timezone?, timeoutSec?, 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. Returns `{ok, id}` |
|
|
189
|
-
| 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. Flips schedule/unschedule on `enabled`. Returns `{ok}` |
|
|
187
|
+
| 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), and `sessionId` (`null` = runs in `cron-<jobId>`) |
|
|
188
|
+
| POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt, schedule, runAt?, timezone?, timeoutSec?, sessionId?, 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. `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 `cron-<jobId>` session. A picked session that already exists keeps its own agent and access level (`agentId` only applies when the cli creates it); see [design/cron.md](../design/cron.md#execution-runjob) for the shared-session guards. Returns `{ok, id}` |
|
|
189
|
+
| 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. `sessionId` likewise: omitted = unchanged, `null` / `''` = back to `cron-<jobId>`, a string is validated same as create. Flips schedule/unschedule on `enabled`. Returns `{ok}` |
|
|
190
190
|
| DELETE | `/api/cron/jobs/:id` | Unschedule, delete every `cron_runs` row for the job, then delete the job. Returns `{ok}` |
|
|
191
191
|
| POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget). Returns `{ok}` |
|
|
192
192
|
| POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
|
|
@@ -317,7 +317,7 @@ File: `packages/server/src/routes/agent-configs.ts`
|
|
|
317
317
|
|
|
318
318
|
| Method | Path | Purpose |
|
|
319
319
|
|---|---|---|
|
|
320
|
-
| GET | `/api/agent-configs?projectId=` | List agents (global + workspace merged) |
|
|
320
|
+
| GET | `/api/agent-configs?projectId=` | List agents (global + workspace merged). A `projectId` with no `.halo/` yields the global agents only and is not scaffolded |
|
|
321
321
|
| GET | `/api/agent-configs/tools` | Available workspace tools |
|
|
322
322
|
| GET | `/api/agent-configs/models` | Available model providers and models |
|
|
323
323
|
| POST | `/api/agent-configs` | Create a new agent |
|
|
@@ -202,7 +202,7 @@ This installs the `halo` binary on `$PATH`. Subcommands available:
|
|
|
202
202
|
2. **Update `CHANGELOG.md`**: rename `[Unreleased]` → `[x.y.z] - YYYY-MM-DD`, add a fresh empty `[Unreleased]` section above it, and roll the link references at the bottom (add `[x.y.z]: compare/v<prev>...vx.y.z`, repoint `[Unreleased]` to `compare/vx.y.z...HEAD`).
|
|
203
203
|
3. **Bump `TEMPLATE_VERSION`** in `packages/server/src/init.ts` if any file under `templates/` was touched. `build-bundle.mjs` enforces this: it diffs `packages/server/templates` against the previous release tag and exits 1 when files changed but the number didn't move (so `pnpm bundle` and every desktop `dist:*` refuse to package a silent template edit).
|
|
204
204
|
4. **Build admin**: `pnpm --filter @turmind/halo-admin build` — verify `admin/out/monaco/vs/loader.js` exists.
|
|
205
|
-
5. Commit, tag `vx.y.z`, push, then `npm publish
|
|
205
|
+
5. Commit, tag `vx.y.z`, push, then `HALO_RELEASE=1 pnpm --filter @turmind/halo-cli bundle` and `npm publish` from `packages/cli/dist-pub/` — **exactly once**. Confirm with `npm view @turmind/halo version` (the registry replicates with a ~2–3 min lag, so wait and re-`view`; never "retry" the publish to see what happens). `dist-pub/` is only written in release mode; desktop `dist:*` builds stage into `dist-dev/` instead, so a stale or sha-suffixed bundle can't end up there — but `npm publish` is still not idempotent (v1.3.4 shipped a `1.3.4-<sha>` prerelease to npm from a second publish and it became `latest` for ~90s).
|
|
206
206
|
6. **GitHub release**: `gh release create vx.y.z --notes-from-tag` (or paste the CHANGELOG section).
|
|
207
207
|
7. **Windows exe** (the step both v1.3.0 and v1.3.1 skipped): `HALO_STAGE_FULL=1 CI=true pnpm dist:win`, then `gh release upload vx.y.z "packages/desktop/dist/Halo Setup x.y.z.exe"`. The macOS dmg is built on a Mac and uploaded the same way.
|
|
208
208
|
8. **Post-check** (whoever ran the release does NOT get to skip this): `gh release view vx.y.z --json assets` must list the exe, and `npm view @turmind/halo version` must print `x.y.z`. A release without the exe attached is not done.
|
|
@@ -121,7 +121,7 @@ dependency work. Three levers, in order of how often they help:
|
|
|
121
121
|
|
|
122
122
|
1. **Auto-fast (automatic).** A full `pnpm dist:arm64` now fingerprints the
|
|
123
123
|
dependency inputs (`packages/server/package.json`, `core/package.json`, the
|
|
124
|
-
cli bundle's `dist-
|
|
124
|
+
cli bundle's `dist-dev/package.json`, `pnpm-lock.yaml`, + target/node
|
|
125
125
|
version) and records it in `resources/.stage-meta`. On the next run, if a
|
|
126
126
|
prior full stage exists for the same target **and the fingerprint is
|
|
127
127
|
unchanged**, staging auto-takes the fast path: it re-syncs only the compiled
|
package/bundled-docs/dev/env.md
CHANGED
|
@@ -124,9 +124,9 @@ settings.yaml only (no env override):
|
|
|
124
124
|
- `general.compact.max_message_slice` (default 800) — local compaction per-message cap
|
|
125
125
|
- `general.compact.summarize_timeout_sec` (default 300) — self-compact timeout
|
|
126
126
|
- `general.server.trust_proxy` (default `false`) — trust `x-forwarded-for` for client IP resolution, scope: global (enable only behind a reverse proxy you control)
|
|
127
|
-
- `general.sandbox.hidden_dirs` (default `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs`) — bwrap tmpfs overlays, scope: global
|
|
128
|
-
- `general.sandbox.writable_dirs` (default empty) — dirs
|
|
129
|
-
- `general.sandbox.hidden_files` (default `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + their `-wal`/`-shm` files) —
|
|
127
|
+
- `general.sandbox.hidden_dirs` (default `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs`) — hidden from workspace/readonly sessions (bwrap tmpfs overlays on Linux, Seatbelt deny on macOS), scope: global
|
|
128
|
+
- `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
|
|
129
|
+
- `general.sandbox.hidden_files` (default `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + their `-wal`/`-shm` files) — hidden from workspace/readonly sessions, read as empty files (bwrap bind of `~/.halo/.sandbox-empty` on Linux, Seatbelt deny on macOS), scope: global
|
|
130
130
|
- `general.logging.level` (default `warn`) — log level: debug | info | warn | error
|
|
131
131
|
- `general.observability.endpoint` (default `''`) — OTLP collector base URL (e.g. `http://localhost:4318`); empty = off, scope: global, restart required. See [design/observability.md](../design/observability.md)
|
|
132
132
|
- `general.observability.service_name` (default `halo`) — OTel resource `service.name`, scope: global, restart required
|
|
@@ -180,4 +180,4 @@ curl -s -o /dev/null -w "%{http_code}" http://localhost:9527
|
|
|
180
180
|
- React functional components; styling via Tailwind only; UI prefers shadcn/ui
|
|
181
181
|
- Log format `[Module] message`
|
|
182
182
|
- Bedrock model ID: `global.anthropic.claude-sonnet-4-6`, default region `us-east-1` (configured per agent via `agent.yaml model.endpoint`)
|
|
183
|
-
- File operations are sandboxed by bwrap
|
|
183
|
+
- File operations are sandboxed for non-full sessions by the OS sandbox (bwrap on Linux; Seatbelt for `shell_exec` on macOS) + `assertPathAllowed` (in-process file tools); `shell_exec` also runs an rm guard at every level — see [tools.md](tools.md#access-level-per-session-dynamic)
|
|
@@ -112,24 +112,46 @@ Timeout 10 s. Max body 50 KB (truncated if larger). Returns status + content-typ
|
|
|
112
112
|
|
|
113
113
|
### Access level (per-session, dynamic)
|
|
114
114
|
|
|
115
|
-
Each session carries `accessLevel: 'readonly' | 'workspace' | null` (persisted in `agent_sessions.access_level`). Sub-sessions inherit their parent's access level. Access level is re-evaluated on every user message — if the channel account's access level has changed, the agent instance is rebuilt with the new tool set and sandbox config.
|
|
115
|
+
Each session carries `accessLevel: 'readonly' | 'workspace' | null` (persisted in `agent_sessions.access_level`). Sub-sessions inherit their parent's access level. Access level is re-evaluated on every user message — if the channel account's access level has changed, the agent instance is rebuilt with the new tool set and sandbox config. In the admin, the level comes from the chat-input selector: the `chat` WS frame carries `accessLevel`, and `handleChat` applies it on the idle path (`'full'`, or any level on a host without an OS sandbox → `null`; a message queued behind a running turn keeps the level that turn was built with).
|
|
116
116
|
|
|
117
|
-
| Level | DB value | Tools (with
|
|
117
|
+
| Level | DB value | Tools (with OS sandbox) | Tools (without) | Sandbox |
|
|
118
118
|
|---|---|---|---|---|
|
|
119
|
-
| `null` (full) | `full` | All 9 tools | All 9 tools | None |
|
|
120
|
-
| `workspace` | `workspace` | All 9 tools | All 9 tools |
|
|
121
|
-
| `readonly` | `readonly` | All 9 tools | file_read, view_image, file_list, grep, glob (5 tools) |
|
|
119
|
+
| `null` (full) | `full` | All 9 tools | All 9 tools | None (rm guard still applies) |
|
|
120
|
+
| `workspace` | `workspace` | All 9 tools | All 9 tools | workspace rw, sensitive paths + workspace runtime state hidden |
|
|
121
|
+
| `readonly` | `readonly` | All 9 tools | file_read, view_image, file_list, grep, glob (5 tools) | workspace ro, sensitive paths + workspace runtime state hidden |
|
|
122
122
|
|
|
123
123
|
`view_image` is additionally gated on `capabilities.image` (see its section above). Models without vision support get the same lists minus `view_image` — so a non-vision model on `readonly` ends up with 4 tools, not 5.
|
|
124
124
|
|
|
125
|
+
OS sandbox backend per platform — `getSandboxBackend()` in `sandbox.ts` returns `'bwrap' | 'seatbelt' | null` after the boot probe (`initBwrapCheck()`), logged at startup as `[Server] OS sandbox: …` and exposed as `sandbox` on `GET /api/health`:
|
|
126
|
+
|
|
127
|
+
| Platform | `shell_exec` | File tools | Dependency |
|
|
128
|
+
|---|---|---|---|
|
|
129
|
+
| Linux | bwrap | bwrap | `bubblewrap` (`apt install bubblewrap`); the probe runs a real sandboxed no-op, so an install that can't create namespaces (Ubuntu 24.04 AppArmor userns restriction) counts as unavailable |
|
|
130
|
+
| macOS | Seatbelt (`/usr/bin/sandbox-exec -p <profile>`) | in-process, behind `assertPathAllowed` | none — ships with macOS (Apple marks it deprecated, but it's present on every release). The probe runs `sandbox-exec -p '(version 1)(allow default)' /usr/bin/true` |
|
|
131
|
+
| Windows | none | none | — see below |
|
|
132
|
+
|
|
125
133
|
Enforcement layers:
|
|
126
|
-
1. **OS sandbox
|
|
127
|
-
|
|
128
|
-
|
|
134
|
+
1. **OS sandbox**
|
|
135
|
+
- **bwrap (Linux)**: `--ro-bind / /` mounts the entire filesystem read-only, then configurable overlays hide sensitive paths (`--tmpfs` for directories; files are covered by `--ro-bind ~/.halo/.sandbox-empty <file>`, a zero-byte file created on demand — **not** `/dev/null`, whose bind reads as EACCES inside bwrap and makes git abort on `~/.gitconfig`). Workspace level adds `--bind` (rw) for the workspace directory. `--tmpfs /tmp` provides isolated writable temp per invocation. `--clearenv` + a minimal `PATH` / `HOME` / `TERM`.
|
|
136
|
+
- **Seatbelt (macOS)**: `buildSeatbeltProfile()` emits `(allow default)` → `(deny file-write*)` → `(allow file-write*)` for the workspace + `writable_dirs` + `/private/tmp`, `/private/var/folders`, `/dev` (readonly: temp dirs and `/dev` only) → `(deny file-read* file-write*)` on every hidden path (global lists + workspace-relative set; `subpath` for dirs, `literal` for files). Later rules win in SBPL, so the final hidden deny overrides the write allow. Paths are realpath'd (Seatbelt matches `/private/tmp`, not `/tmp`). The child gets a minimal env with `GIT_CONFIG_GLOBAL=/dev/null` and `NPM_CONFIG_USERCONFIG=/dev/null` so git / npm don't trip over the denied `~/.gitconfig` / `~/.npmrc`. Unlike bwrap there's no private `/tmp` — writes there persist.
|
|
137
|
+
- Both backends pass the host git identity (`git config --global user.name/user.email`, read once at boot) as `GIT_AUTHOR_*` / `GIT_COMMITTER_*`, so `git commit` works with `~/.gitconfig` hidden. Nothing else from the host git config crosses over.
|
|
138
|
+
- Tool execution uses `execFileAsync` / `spawn` (not shell) to prevent escape. Error messages are sanitized to strip sandbox internals — the agent never sees bwrap flags or mount details.
|
|
139
|
+
- When a non-full `shell_exec` fails with a write-denial (`Read-only file system` / `Operation not permitted` / `Permission denied` / `EROFS` / `EPERM`), the tool result gets a `[Sandbox] This session runs at "<level>" access: …` hint telling the agent to ask the user to switch to Full in the chat input box, rather than retrying variations.
|
|
140
|
+
2. **Tool filtering (no OS sandbox only)**: when `getSandboxBackend()` is `null`, `createWorkspaceTools()` returns a reduced 5-tool set for readonly (no file_write, file_edit, shell_exec, web_fetch). Workspace retains all tools.
|
|
141
|
+
3. **In-process path validation**: `assertPathAllowed()` gates every file-tool path whenever the file tools don't run inside bwrap (macOS always; Linux without bwrap). Same rules as the OS sandbox: **read** anywhere except the hidden sets (global + workspace-relative), **write** only to the workspace (minus the hidden set) and `writable_dirs`, readonly never writes. Paths are realpath'd first, so a symlink is judged by its target. Without any OS sandbox, `shell_exec` is blocked entirely for non-full sessions.
|
|
142
|
+
|
|
143
|
+
On Linux without bwrap only layers 2 + 3 are active.
|
|
144
|
+
|
|
145
|
+
#### rm guard
|
|
146
|
+
|
|
147
|
+
`assertRmSafe(command, workspaceRoot)` runs before every `shell_exec` at **every** access level (full included) on every platform except Windows — the OS sandbox limits where writes land, but not a mistyped `rm -r` inside the writable area. It refuses an `rm` / `rmdir` whose target resolves to:
|
|
148
|
+
- `/`, `$HOME`, `~/.halo`, the workspace root, or any parent of those
|
|
149
|
+
- a system directory or its direct child (`/etc`, `/usr`, `/var`, `/home`, `/opt`, … plus macOS `/Applications`, `/Library`, `/System`, `/Users`, `/Volumes`, `/private`)
|
|
150
|
+
- `/tmp`, `/private/tmp`, `/root` themselves (their children are ordinary deletes — `/root` is `$HOME` in most containers)
|
|
129
151
|
|
|
130
|
-
|
|
152
|
+
Parsing: the command is split into simple commands on unquoted `;` `&` `|` newline, quotes stripped, heredoc bodies skipped (they're data being written, not commands). Leading wrappers / keywords (`sudo`, `env`, `xargs`, `nohup`, `time`, `if`/`then`/`do`/`while`, `VAR=…` assignments) are skipped to find the real command; `cd` earlier in the line moves the base for relative targets (default base = workspace root). `~`, `$HOME`, `$PWD` / `$(pwd)` expand; any other variable or substitution expands to empty — its value when unset, which is how `rm -rf "$DIR/"` goes wrong. A glob meaning "everything in X" (`*`, `.*`, `**`) is judged by X; narrower globs (`*.log`) pass. A block throws `[Sandbox] rm blocked: "<word>" resolves to <path>, which is <reason>. Name the specific files or subdirectories to delete instead.` It's a heuristic against mistakes, not a parser — `eval`, `bash -c "…"`, scripts and command substitution aren't followed.
|
|
131
153
|
|
|
132
|
-
**Windows has no sandbox.** There is no bwrap equivalent, so `sandbox.ts` (`normalizeOptsForPlatform`) promotes every non-full call to `full` before it reaches layer 1 or layer 3 — `assertPathAllowed()` returns immediately without checking the workspace boundary or the hidden-path lists. Only layer 2 (tool filtering) survives, because `
|
|
154
|
+
**Windows has no sandbox.** There is no bwrap / Seatbelt equivalent, so `sandbox.ts` (`normalizeOptsForPlatform`) promotes every non-full call to `full` before it reaches layer 1 or layer 3 — `assertPathAllowed()` returns immediately without checking the workspace boundary or the hidden-path lists, and the rm guard is skipped (cmd syntax, no `rm`). Only layer 2 (tool filtering) survives, because `getSandboxBackend()` is always `null` there. The admin's access-level selector reads `sandbox: null` from `/api/health` and locks itself to Full. Net effect on Windows:
|
|
133
155
|
|
|
134
156
|
| Level | Effective behavior on Windows |
|
|
135
157
|
|---|---|
|
|
@@ -141,33 +163,30 @@ This is a known, unfixed gap (not a bug in a specific route): don't hand out `wo
|
|
|
141
163
|
|
|
142
164
|
### Sandbox hidden paths
|
|
143
165
|
|
|
144
|
-
Sensitive directories and files are hidden from workspace/readonly sessions via bwrap overlays (and the same lists gate `assertPathAllowed`
|
|
166
|
+
Sensitive directories and files are hidden from workspace/readonly sessions via bwrap overlays / Seatbelt deny rules (and the same lists gate `assertPathAllowed` for in-process file tools). Under bwrap a hidden file reads as empty and a hidden dir as an empty directory; under Seatbelt both are denied outright. Paths that don't exist on the filesystem are silently skipped. Two categories coexist:
|
|
145
167
|
|
|
146
168
|
**Global lists (configurable)** — credentials and cross-workspace state, configured in `settings.yaml` under `general.sandbox`:
|
|
147
169
|
|
|
148
170
|
| Setting | Default | Method |
|
|
149
171
|
|---|---|---|
|
|
150
|
-
| `hidden_dirs` | `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs` | `--tmpfs` overlay (empty directory) |
|
|
151
|
-
| `hidden_files` | `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + their `-wal`/`-shm` files | `--ro-bind
|
|
152
|
-
| `writable_dirs` | (empty) | `--bind` read-write — for external CLIs that keep local state (e.g. `~/.kiro`); not applied to readonly sessions |
|
|
172
|
+
| `hidden_dirs` | `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs` | bwrap `--tmpfs` overlay (empty directory); Seatbelt `subpath` deny |
|
|
173
|
+
| `hidden_files` | `~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs}.db` + their `-wal`/`-shm` files | bwrap `--ro-bind ~/.halo/.sandbox-empty` (reads as empty); Seatbelt `literal` deny |
|
|
174
|
+
| `writable_dirs` | (empty) | bwrap `--bind` read-write / Seatbelt write allow — for external CLIs that keep local state (e.g. `~/.kiro`); not applied to readonly sessions |
|
|
153
175
|
|
|
154
|
-
Changes take effect immediately — `
|
|
176
|
+
Changes saved through the settings API (admin Settings page, `PUT` / `PATCH /api/settings`) take effect immediately — the save fires `onSettingsChange`, which re-reads the lists into `sandbox.ts` (`setSandboxHiddenPaths`), so the next tool call uses them. A hand edit of `settings.yaml` is not watched for these keys: it applies at the next restart or the next API save. These keys are `globalOnly` in the schema — a workspace `settings.yaml` cannot override them, since they define the security boundary agents run inside.
|
|
155
177
|
|
|
156
178
|
**Workspace-relative set (hardcoded)** — the workspace's own runtime state, which holds other channels'/users' conversations on a shared workspace. Code constants in `sandbox.ts` (`WORKSPACE_HIDDEN_DIRS` / `WORKSPACE_HIDDEN_FILES`), deliberately not settings: this is a security boundary (a config edit must not be able to open it), and the entries are workspace-relative while the settings lists are absolute/`~` paths.
|
|
157
179
|
|
|
158
180
|
| Constant | Entries | Method |
|
|
159
181
|
|---|---|---|
|
|
160
|
-
| `WORKSPACE_HIDDEN_DIRS` | `.halo/sessions` (session transcripts), `.halo/logs`, `.halo/evo` (run dirs contain full source-session snapshots) | `--tmpfs` overlay |
|
|
161
|
-
| `WORKSPACE_HIDDEN_FILES` | `.halo/halo.db` + `-wal`/`-shm` (sqlite `agent_sessions` rows) | `--ro-bind /
|
|
182
|
+
| `WORKSPACE_HIDDEN_DIRS` | `.halo/sessions` (session transcripts), `.halo/logs`, `.halo/evo` (run dirs contain full source-session snapshots) | `--tmpfs` overlay / Seatbelt `subpath` deny |
|
|
183
|
+
| `WORKSPACE_HIDDEN_FILES` | `.halo/halo.db` + `-wal`/`-shm` (sqlite `agent_sessions` rows) | `--ro-bind ~/.halo/.sandbox-empty` / Seatbelt `literal` deny |
|
|
162
184
|
|
|
163
185
|
The rest of `.halo/` (INSTRUCTIONS.md, INDEX.md, docs/, memory/, skills/, agents/, prompts/, tmp/, canvas/, goal/, settings.yaml) stays readable — it's workspace knowledge agents need to work. `full` sessions bypass the sandbox entirely and see everything.
|
|
164
186
|
|
|
165
|
-
`/tmp` is not in the hidden list — it receives a standalone `--tmpfs` mount for process isolation (each
|
|
187
|
+
`/tmp` is not in the hidden list — under bwrap it receives a standalone `--tmpfs` mount for process isolation (each invocation gets its own empty `/tmp`), not for hiding secrets. Seatbelt has no mount namespace, so on macOS `/tmp` is the real one and writes to it persist.
|
|
166
188
|
|
|
167
|
-
Per-channel defaults:
|
|
168
|
-
- **Web** — inherits account's `access_level` (default `full`)
|
|
169
|
-
- **Telegram** — inherits account's `access_level` (default `readonly`)
|
|
170
|
-
- **WeChat** — inherits account's `access_level` (default `readonly`)
|
|
189
|
+
Per-channel defaults: every channel session (Web, Telegram, Slack, Feishu, WeCom, WeChat) inherits its account's `access_level` — `full` → full, `workspace` → workspace, `readonly` / `observer` → readonly — and new accounts default to `readonly` (DB column default, `insertAccount` fallback and the admin create forms). Level sources for every entry point (admin, cron, cli, relay, goal, sub-agents): [guide/delegation-and-access.md](../guide/delegation-and-access.md#where-a-sessions-level-comes-from).
|
|
171
190
|
|
|
172
191
|
### Binary file detection
|
|
173
192
|
`grep` and `glob` read the first 512 bytes looking for a null byte and skip binaries.
|
|
@@ -10,6 +10,7 @@ Aimed at Halo end-users. Pick the doc that matches your situation.
|
|
|
10
10
|
4. [agents.md](agents.md) — creating, editing, configuring agents
|
|
11
11
|
5. [skills.md](skills.md) — skill concepts, creating skills, attaching them to agents
|
|
12
12
|
6. [sessions.md](sessions.md) — viewing session history, debug mode, multi-agent collaboration
|
|
13
|
+
7. [delegation-and-access.md](delegation-and-access.md) — runtime rules: scope override, when edits take effect, team/roster, access levels
|
|
13
14
|
|
|
14
15
|
## Channels — talk to your agent from somewhere other than the admin UI
|
|
15
16
|
|
|
@@ -81,6 +81,8 @@ team: # a non-empty team is what enables delegation
|
|
|
81
81
|
|
|
82
82
|
Form view lets you check workspace tools on/off and pick the team; there's no separate session-tools checklist — delegation rides entirely on the team.
|
|
83
83
|
|
|
84
|
+
Roster rules, `start_session` checks and errors, access-level inheritance, and when an edit takes effect: [delegation-and-access.md](delegation-and-access.md).
|
|
85
|
+
|
|
84
86
|
## Skill mounting
|
|
85
87
|
|
|
86
88
|
`skills: [skill_id_1, skill_id_2]` — Halo injects skill metadata into the prompt, and the agent calls `activate_skill` to load the full SKILL.md when needed.
|
|
@@ -10,6 +10,16 @@ The dropdown to the left of the input lists every agent (global + workspace).
|
|
|
10
10
|
- Once you manually pick an agent, that choice is remembered across `clear` / new sessions until you pick something else.
|
|
11
11
|
- **A conversation is locked to one agent**: once you start chatting, the dropdown locks. To change agent, start a new session (`/session new`).
|
|
12
12
|
|
|
13
|
+
## Access level
|
|
14
|
+
|
|
15
|
+
The leftmost button of the input toolbar sets what the agent is allowed to change:
|
|
16
|
+
|
|
17
|
+
- **Full** — no restrictions.
|
|
18
|
+
- **Workspace** — the agent can read anywhere but write only inside this workspace. Credential files such as `~/.gitconfig` and `~/.git-credentials` are hidden from it. `git commit` still works, using your git name and email.
|
|
19
|
+
- **Readonly** — the agent can't write anything.
|
|
20
|
+
|
|
21
|
+
A change applies from the next message you send, and the level is saved per session. If a command fails because of the level, the agent will ask you to switch to Full instead of retrying. The button is locked to Full when the host can't enforce the other levels (Windows, or Linux without bubblewrap). On macOS the built-in `sandbox-exec` is used.
|
|
22
|
+
|
|
13
23
|
## Sending messages
|
|
14
24
|
|
|
15
25
|
- Enter to send
|
|
@@ -18,8 +18,11 @@ pnpm --filter @turmind/halo-cli link --global
|
|
|
18
18
|
|
|
19
19
|
The published package is `@turmind/halo` — a self-contained esbuild bundle
|
|
20
20
|
(CLI + server + admin-out + templates), staged under `packages/cli/dist-pub/`
|
|
21
|
-
by `scripts/build-bundle.mjs
|
|
22
|
-
|
|
21
|
+
by `scripts/build-bundle.mjs` when run with `HALO_RELEASE=1`. Without that flag
|
|
22
|
+
(desktop packaging, local bundle tests) the script writes a sha-suffixed build to
|
|
23
|
+
`packages/cli/dist-dev/` instead and never touches `dist-pub/`. The workspace
|
|
24
|
+
`@turmind/halo-cli` can't be published directly (it has `workspace:*` deps); the
|
|
25
|
+
bundle flattens them.
|
|
23
26
|
|
|
24
27
|
```bash
|
|
25
28
|
# 1. bump packages/cli/package.json "version" (e.g. 0.1.2)
|
|
@@ -30,11 +33,13 @@ cd packages/admin && npx next build --no-lint && node scripts/copy-monaco.mjs &&
|
|
|
30
33
|
# 3. stage the release bundle — HALO_RELEASE=1 stamps the bare version
|
|
31
34
|
# (0.1.2), NOT the default <version>-<sha>: a `-<sha>` suffix is a semver
|
|
32
35
|
# prerelease that `npm install` skips and never tags as `latest`.
|
|
36
|
+
# It also selects dist-pub/ as the output dir (dev builds go to dist-dev/).
|
|
33
37
|
HALO_RELEASE=1 node packages/cli/scripts/build-bundle.mjs
|
|
34
|
-
# 4. preview + publish
|
|
38
|
+
# 4. preview + publish — publish ONCE, then confirm with `npm view`
|
|
35
39
|
cd packages/cli/dist-pub
|
|
36
40
|
npm pack --dry-run # inspect tarball contents
|
|
37
41
|
npm publish # @turmind/halo@<version>, tag latest, public
|
|
42
|
+
npm view @turmind/halo version # may lag 2-3 min; re-view, don't re-publish
|
|
38
43
|
```
|
|
39
44
|
|
|
40
45
|
npm versions are immutable — a published version can never be overwritten,
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Delegation and Access
|
|
2
|
+
|
|
3
|
+
How Halo decides, at runtime, **which agent / skill files are used**, **when a config edit takes effect**, **who an agent may delegate to**, and **what access level a session runs at**. Written for people (and agents such as a workspace builder) who configure agents and need the actual behavior without reading source.
|
|
4
|
+
|
|
5
|
+
Field-by-field `agent.yaml` reference: [agents.md](agents.md). Per-tool schemas and sandbox internals: [dev/tools.md](../dev/tools.md).
|
|
6
|
+
|
|
7
|
+
## 1. Which files are used — scope resolution
|
|
8
|
+
|
|
9
|
+
| Item | Workspace location | Global location | Rule |
|
|
10
|
+
|---|---|---|---|
|
|
11
|
+
| Agent | `<ws>/.halo/agents/<id>/` | `~/.halo/global/agents/<id>/` | **Whole-folder override.** If the workspace folder exists, `agent.yaml` and `AGENT.md` are read **only** from it — no per-file fallback to global. |
|
|
12
|
+
| Skill | `<ws>/.halo/skills/<id>/` | `~/.halo/global/skills/<id>/` | Whole-folder override, including resource files next to `SKILL.md`. |
|
|
13
|
+
| System prompts | `<ws>/.halo/prompts/<scope>/` | `~/.halo/global/prompts/<scope>/` | Per scope (`bootstrap` / `all` / `root`): a workspace scope dir replaces the global one. |
|
|
14
|
+
| INSTRUCTIONS.md | `<ws>/.halo/INSTRUCTIONS.md` | `~/.halo/global/INSTRUCTIONS.md` | Workspace file **replaces** global (not stacked). |
|
|
15
|
+
| USER.md | `<ws>/.halo/USER.md` | `~/.halo/global/USER.md` | Workspace file replaces global. |
|
|
16
|
+
| Disabled flag | `disabled_items` in the workspace `halo.db` | — | Checked against the scope that actually serves the id (workspace if its folder exists, else global). |
|
|
17
|
+
|
|
18
|
+
Gotchas:
|
|
19
|
+
- An empty or half-copied `<ws>/.halo/agents/<id>/` folder **shadows** the global agent completely. With no `agent.yaml` inside, the agent can't be used: `start_session` returns "not found" and opening a session fails with "agent.yaml missing or unreadable" — yet rosters and pickers may still show the global copy. When customizing, copy both files; an `agent.yaml` without `model.provider` / `model.id` / `model.endpoint` fails with "missing model config".
|
|
20
|
+
- Same id in both scopes: everything (roster, chat selector, delegation) sees only the workspace version.
|
|
21
|
+
- Built-in agents (`default`, `executor`, `deep-executor`, `goal`, and the internal `__evo_agent__` / `__score__` / `__apply_agent__`) and built-in skills are platform-owned: a template reseed after an upgrade overwrites them (an agent's `model:` / `context:` blocks are kept). Customize through a workspace copy, not by editing the global files.
|
|
22
|
+
|
|
23
|
+
## 2. When a config edit takes effect
|
|
24
|
+
|
|
25
|
+
No restart is needed for agent / skill / prompt edits. An agent instance (model runtime, tool set, system prompt) is built from disk when a session is loaded into memory, and a session is **released from memory at the end of every run**. The next message rebuilds it from the current files.
|
|
26
|
+
|
|
27
|
+
| You edit | Takes effect |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `agent.yaml` (model, tools, skills, team, context, thinking), `AGENT.md` | Next turn of any existing session; immediately for new sessions (including sub-agents started after the edit) |
|
|
30
|
+
| `INSTRUCTIONS.md`, `USER.md`, `INDEX.md`, `prompts/` | Same — next turn / new sessions |
|
|
31
|
+
| Skill frontmatter (`name`, `description`, `requiresAccess`), adding/removing a skill id in `skills:` | Next turn / new sessions |
|
|
32
|
+
| `SKILL.md` **body** | Immediately — `activate_skill` reads the file at call time |
|
|
33
|
+
| `team` list — the `start_session` / `query_agent` check | Immediately (the caller's `agent.yaml` is re-read on every call) |
|
|
34
|
+
| `team` list — the roster in the prompt, and gaining/losing the session tools (empty ↔ non-empty) | Next turn |
|
|
35
|
+
| Disable / enable an agent | `start_session` / `query_agent` reject a disabled agent immediately; rosters update next turn |
|
|
36
|
+
| `general.sandbox.hidden_dirs` / `hidden_files` / `writable_dirs` | Immediately when saved from the admin Settings page; a hand edit of `settings.yaml` applies after a restart (or the next Settings save) |
|
|
37
|
+
| `general.session.max_nesting_depth` | **Server restart only** — read once at startup |
|
|
38
|
+
|
|
39
|
+
What "next turn" means precisely:
|
|
40
|
+
- The turn that is running now finishes on the old config. Messages queued behind it and drained in the same run also use the old config.
|
|
41
|
+
- A session that was only loaded for viewing (context panel, `/context`, compact) runs its next turn on the config it was loaded with, then picks up the new config after that.
|
|
42
|
+
- An access-level change on a session rebuilds it immediately, with messages kept (see §5).
|
|
43
|
+
|
|
44
|
+
## 3. Delegation — team, roster, start_session
|
|
45
|
+
|
|
46
|
+
**`team` is the on/off switch.** An agent gets the 8 session tools (`start_session`, `session_list`, `query_session`, `interrupt_session`, `stop_session`, `archive_session`, `get_session_output`, `query_agent`) only when its `agent.yaml` has a **non-empty `team`**. Absent or empty `team` means no session tools and no roster; the agent still has `continue_task`. Listing session tools under `tools:` does nothing. Internal agents never delegate, whatever their `team` says.
|
|
47
|
+
|
|
48
|
+
**Roster** (`## Your Team` block, placed right after AGENT.md in the system prompt):
|
|
49
|
+
- Candidates: every agent in both scopes, collapsed to the effective record per id (workspace shadows global).
|
|
50
|
+
- Removed: disabled agents, `internal: true` agents, and anything not in `team`.
|
|
51
|
+
- The agent itself is listed first, tagged `(you)`, only if its own id is in `team` (that is what allows parallel self-spawn). The rest are sorted by `priority`, highest first, as `` `id` — name: description ``.
|
|
52
|
+
- A workspace agent may list global agents in its `team` and vice versa — ids are resolved to whatever scope serves them.
|
|
53
|
+
|
|
54
|
+
**`start_session` checks, in order** (the first failure is returned as `{"code": 1, "error": …}`):
|
|
55
|
+
|
|
56
|
+
| # | Check | Error |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| 1 | `agent_id` and `message` present | `agent_id and message are required` |
|
|
59
|
+
| 2 | Target exists, is not `internal`, is not disabled | `agent "<id>" not found. The agents you can delegate to are listed in your system prompt.` |
|
|
60
|
+
| 3 | Target is in the caller's `team` | `agent "<id>" is not in your team. …` |
|
|
61
|
+
| 4 | Caller's depth < `max_nesting_depth` | `Maximum nesting depth (16) reached. Complete this task directly instead of delegating to a sub-agent.` |
|
|
62
|
+
| 5 | `working_dir` (optional) is inside the workspace and is an existing directory | `working_dir "<x>" is outside the workspace` / `is not a directory` / `does not exist` |
|
|
63
|
+
|
|
64
|
+
On success the child is created at the **parent's access level** (§5) and runs asynchronously. When it goes idle its wrap-up is reported back to the parent automatically.
|
|
65
|
+
|
|
66
|
+
**Nesting depth.** Session ids are `root>child>grandchild…`; depth is the number of segments (a root session is depth 1). A caller at depth ≥ `max_nesting_depth` cannot spawn. With the default 16, a tree has at most 16 levels. Set it in `~/.halo/secrets/settings.yaml` under `general.session.max_nesting_depth` (global only, a workspace `settings.yaml` can't override it) and restart the server.
|
|
67
|
+
|
|
68
|
+
**The other session tools:**
|
|
69
|
+
- `query_agent <id>`: same checks as rows 2–3 above, but every failure reads `agent "<id>" not found.`. Returns name, description, model, `tools`, and skills — **not** AGENT.md. When the caller is readonly, `file_write` / `file_edit` / `shell_exec` / `web_fetch` are dropped from the listed tools, and skills whose `requiresAccess` is above the caller's level are hidden.
|
|
70
|
+
- `query_session` / `interrupt_session` / `stop_session` / `archive_session` / `get_session_output`: only work on sessions in the caller's own tree (same root id). Anything else returns `session <id> not found`.
|
|
71
|
+
- `session_list`: direct children of the calling session only (up to 500).
|
|
72
|
+
|
|
73
|
+
**Delegating further.** A sub-agent delegates only if its own `agent.yaml` has a non-empty `team`. It gets its own roster. Depth is the only other limit.
|
|
74
|
+
|
|
75
|
+
## 4. What a sub-agent's system prompt contains
|
|
76
|
+
|
|
77
|
+
| Layer | Root session | Sub-agent | Internal agent |
|
|
78
|
+
|---|---|---|---|
|
|
79
|
+
| USER.md | ✓ | — | — |
|
|
80
|
+
| AGENT.md (+ roster if `team`) | ✓ | ✓ | AGENT.md only |
|
|
81
|
+
| INSTRUCTIONS.md (workspace, else global) | ✓ | ✓ | — |
|
|
82
|
+
| INSTRUCTIONS.md along the `working_dir` path (stacked, general → specific) | if `working_dir` set | if `working_dir` set | if `working_dir` set |
|
|
83
|
+
| INDEX.md | ✓ | ✓ | — |
|
|
84
|
+
| `prompts/root` | ✓ | — | — |
|
|
85
|
+
| `prompts/all` | ✓ | ✓ | — |
|
|
86
|
+
| Skill metadata (`skills:` minus disabled minus `requiresAccess` above the session level) | ✓ | ✓ | ✓ |
|
|
87
|
+
| Trailing "Your available tools: …" line | ✓ | ✓ | ✓ |
|
|
88
|
+
|
|
89
|
+
So a sub-agent knows nothing about the user except what the parent puts in the `start_session` message / `system_prompt_context`.
|
|
90
|
+
|
|
91
|
+
## 5. Access levels
|
|
92
|
+
|
|
93
|
+
Three session levels, stored per session (`agent_sessions.access_level`; `full` is stored as null):
|
|
94
|
+
|
|
95
|
+
| Level | Tools the agent can get (from its `tools:` list) | Sandbox |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `full` | all 9 workspace tools | none (the `rm` guard still applies) |
|
|
98
|
+
| `workspace` | all 9 | writes only inside the workspace; credentials and workspace runtime state are hidden |
|
|
99
|
+
| `readonly` | all 9 with an OS sandbox (writes fail); only `file_read`, `view_image`, `file_list`, `grep`, `glob` without one | nothing writable |
|
|
100
|
+
|
|
101
|
+
Extra rules:
|
|
102
|
+
- Without an OS sandbox (Linux without working bubblewrap, and Windows), `shell_exec` fails for every non-full session. Windows has no path boundary at all — see [dev/tools.md](../dev/tools.md#access-level-per-session-dynamic).
|
|
103
|
+
- A readonly `shell_exec` gets no secret substitution — `{{…params…}}` / `<<ENV>>` placeholders stay literal.
|
|
104
|
+
- `view_image` also needs a vision-capable model.
|
|
105
|
+
- A skill with `requiresAccess` above the session level is hidden (no metadata, no `activate_skill`).
|
|
106
|
+
- Relay tools are only built for `full` sessions.
|
|
107
|
+
- Hidden from `workspace` / `readonly` sessions: the `general.sandbox.hidden_dirs` / `hidden_files` lists (default: `~/.halo/secrets`, `~/.aws`, `~/.ssh`, `~/.gnupg`, `~/.docker`, `~/.config/gh`, `~/.gitconfig`, `~/.git-credentials`, `~/.npmrc`, `~/.netrc`, the global evo/cron/runs databases, internal-session transcripts, logs, …) plus the workspace's own `.halo/sessions`, `.halo/logs`, `.halo/evo`, `.halo/halo.db*` (fixed in code). The rest of `.halo/` stays readable.
|
|
108
|
+
|
|
109
|
+
### Where a session's level comes from
|
|
110
|
+
|
|
111
|
+
| Entry point | Level of a new session | Existing session |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| Admin chat | Chat-input selector (defaults to Full; locked to Full on a host without an OS sandbox) | Re-applied on each message sent while the session is idle. A message queued behind a running turn keeps the running turn's level. |
|
|
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
|
+
| Channel `/session new` | The account's level | — |
|
|
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`) |
|
|
118
|
+
| `halo cli` / TUI | `--access`, default `full` | `--access` value (a different level is persisted) |
|
|
119
|
+
| `relay_send` into another workspace | Full | Keeps its stored level |
|
|
120
|
+
| `/goal create` | The goal session is full (`/goal` verbs need full access) | — |
|
|
121
|
+
| AgentCore runtime | Full | — |
|
|
122
|
+
|
|
123
|
+
Consequences:
|
|
124
|
+
- A sub-agent can never have more access than its parent had when it was spawned. A readonly channel user can't escalate by delegating.
|
|
125
|
+
- To give a sub-agent more access, raise the root session's level first (admin selector / channel account), then spawn a **new** child.
|
|
126
|
+
|
|
127
|
+
## 6. Relay (cross-workspace)
|
|
128
|
+
|
|
129
|
+
Relay is separate from `team` delegation:
|
|
130
|
+
- Enabled by listing `relay_send` in `tools:` (that one name brings `relay_send` / `relay_interrupt` / `relay_stop` / `relay_read` / `relay_list`), and **only in full sessions** — a workspace/readonly session with `relay_send` listed gets nothing.
|
|
131
|
+
- **Not team-checked.** Any agent in the target workspace can be named with `agent_id`. Without it, the target workspace's default entry agent is used: highest `priority`, not disabled, not internal, workspace scope wins ties.
|
|
132
|
+
- The target session is created at full if missing, otherwise keeps its level. Its wrap-up comes back as a `[Relay report …]` message, once per dispatch.
|
|
133
|
+
- Server only (not in `halo cli` / TUI). Details: [dev/tools.md → Relay tools](../dev/tools.md#relay-tools).
|
|
134
|
+
|
|
135
|
+
## 7. Quick answers
|
|
136
|
+
|
|
137
|
+
| Question | Answer |
|
|
138
|
+
|---|---|
|
|
139
|
+
| I edited `agent.yaml` / AGENT.md — restart? | No. Next turn of existing sessions, immediately for new ones (§2). |
|
|
140
|
+
| The agent has no `start_session` | `team` is empty or missing — add at least one id. It shows up from the next turn. |
|
|
141
|
+
| `start_session` says "not found" but the agent exists | It's disabled, `internal`, or shadowed by a workspace folder with no `agent.yaml` (§1). |
|
|
142
|
+
| "is not in your team" | Add the id to the **caller's** `team`. Takes effect on the next call. |
|
|
143
|
+
| Sub-agent has fewer tools than its `tools:` list | It inherited a restricted level from its parent (§5), or the model has no vision (`view_image`). |
|
|
144
|
+
| Sub-agent can't see the user's preferences | Sub-agents don't get USER.md (§4). Put what they need in the brief. |
|
|
145
|
+
| Delegation stops at some depth | `max_nesting_depth` (default 16, global setting, restart to change). |
|
|
146
|
+
| Make an agent the default for new channel sessions | Give it the highest `priority` in that workspace. |
|
|
147
|
+
| Hand work to another workspace | Relay: `relay_send` in `tools:`, full session (§6). |
|
|
148
|
+
|
|
149
|
+
## Where this lives in code
|
|
150
|
+
|
|
151
|
+
For maintainers keeping this page in sync — paths under `packages/server/src/`:
|
|
152
|
+
|
|
153
|
+
| Topic | File |
|
|
154
|
+
|---|---|
|
|
155
|
+
| Scope resolution, `canDelegate`, `isTeamMember`, `isAgentDisabled` | `agents/agent-loader.ts` |
|
|
156
|
+
| Tool set, roster, prompt composition | `agents/session-agent-builder.ts`, `prompts/md-loader.ts`, `prompts/system-prompts.ts` |
|
|
157
|
+
| Session tool checks | `agents/session-tools.ts` |
|
|
158
|
+
| Release after each run, access-level rebuild | `agents/session-manager.ts` (`runSession` finally → `releaseSession`; `sendUserMessage`) |
|
|
159
|
+
| Channel level mapping | `channels/shared/inbound.ts` (`sessionAccess`), `channels/web/handler.ts` |
|
|
160
|
+
| Tool filtering and sandbox | `tools/workspace-tools.ts`, `tools/sandbox.ts` |
|
|
161
|
+
| Relay | `agents/relay.ts` |
|
|
@@ -93,7 +93,7 @@ Agents can manage other sessions via tools. The whole bundle is granted automati
|
|
|
93
93
|
| `get_session_output` | Read a session's latest turn output |
|
|
94
94
|
| `query_agent` | Inspect another agent (team-gated to the roster) |
|
|
95
95
|
|
|
96
|
-
See [dev/tools.md](../dev/tools.md).
|
|
96
|
+
A sub-agent runs at its parent's access level. See [delegation-and-access.md](delegation-and-access.md) for team/roster rules, errors, nesting depth and access levels, and [dev/tools.md](../dev/tools.md) for tool schemas.
|
|
97
97
|
|
|
98
98
|
## Scenarios
|
|
99
99
|
|
|
@@ -11,6 +11,14 @@ The primary surface for talking to an agent.
|
|
|
11
11
|
- **Locked during an active session** — the agent is bound to the session; to change, start a new session (/session new)
|
|
12
12
|
- The Agents panel's "Test" button can also preselect an agent
|
|
13
13
|
|
|
14
|
+
### Access level
|
|
15
|
+
- Leftmost control of the input toolbar (just left of the attach button), fixed width so switching doesn't shift the row; colored badge per level — Full (amber) / Workspace (blue) / Readonly (emerald). The dropdown shows each level with a one-line description
|
|
16
|
+
- **Full** — no restrictions; **Workspace** — writes limited to this workspace; **Readonly** — read only, no writes (OS-sandbox semantics in [dev/tools.md](../dev/tools.md#access-level-per-session-dynamic))
|
|
17
|
+
- Applies to the **next message** sent while the session is idle; disabled while a response is streaming (a message queued mid-turn runs at the level already in effect)
|
|
18
|
+
- Opening an existing session shows its stored level; a new session starts at Full, and a level picked before the first send sticks
|
|
19
|
+
- **Locked to Full** when the host has no OS sandbox (`/api/health` `sandbox: null` — e.g. Windows, or Linux without bubblewrap); the tooltip says why
|
|
20
|
+
- When a command fails because of the sandbox, the agent is told to ask the user to switch to Full here rather than retrying
|
|
21
|
+
|
|
14
22
|
### Message rendering
|
|
15
23
|
- Markdown + code-block highlighting
|
|
16
24
|
- Tool-call card: expandable, shows tool name / input / output
|