@turmind/halo 1.6.0 → 1.6.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/58.d26b1c4f14404226.js +1 -0
- package/admin-out/_next/static/chunks/989-ddba0eb72566da23.js +1 -0
- package/admin-out/_next/static/chunks/app/page-be47983ba8ff20b1.js +1 -0
- package/admin-out/_next/static/chunks/webpack-74f577c1b094f942.js +1 -0
- package/admin-out/_next/static/css/3b4dcd0ac6b8eae8.css +1 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/agent.md +6 -4
- package/bundled-docs/design/canvas-extensions.md +30 -4
- package/bundled-docs/design/cron.md +22 -11
- package/bundled-docs/design/prompt-system.md +10 -5
- package/bundled-docs/design/session.md +4 -1
- package/bundled-docs/design/storage.md +31 -8
- package/bundled-docs/design/ws.md +1 -1
- package/bundled-docs/dev/add-model-provider.md +15 -9
- package/bundled-docs/dev/api.md +23 -10
- package/bundled-docs/dev/deploy.md +3 -0
- package/bundled-docs/dev/desktop-packaging.md +11 -5
- package/bundled-docs/dev/env.md +1 -1
- package/bundled-docs/dev/previews.md +26 -0
- package/bundled-docs/guide/agents.md +6 -6
- package/bundled-docs/guide/cli.md +5 -4
- package/bundled-docs/guide/skills.md +12 -6
- package/bundled-docs/guide/workspace.md +1 -1
- package/bundled-docs/requirements/agent.md +5 -0
- package/bundled-docs/requirements/chat.md +7 -3
- package/bundled-docs/requirements/command.md +4 -0
- package/bundled-docs/requirements/editor.md +20 -6
- package/bundled-docs/requirements/skill.md +15 -9
- package/dist/index.js +2263 -1324
- package/package.json +1 -1
- package/templates/models/anthropic.yaml +109 -13
- package/templates/models/aws-bedrock-claude-invoke.yaml +39 -13
- package/templates/models/aws-bedrock-mantle.yaml +60 -28
- package/templates/models/aws-bedrock-openai.yaml +52 -29
- package/templates/models/deepseek.yaml +7 -3
- package/templates/models/doubao.yaml +72 -20
- package/templates/models/hunyuan.yaml +74 -14
- package/templates/models/kimi.yaml +63 -3
- package/templates/models/mimo-token-plan-china.yaml +9 -1
- package/templates/models/minimax.yaml +9 -3
- package/templates/models/openai.yaml +120 -17
- package/templates/models/qwen.yaml +90 -4
- package/templates/models/zhipu.yaml +9 -1
- package/templates/schema.sql +1 -1
- package/templates/skills/cron/SKILL.md +38 -2
- package/templates/skills/cron/manage-cron.py +115 -7
- package/templates/skills/skill/SKILL.md +22 -11
- package/templates/skills/workspace/share.md +5 -2
- package/templates/skills/workspace/stage.py +51 -14
- package/admin-out/_next/static/chunks/220.7566bc89f3d98993.js +0 -1
- package/admin-out/_next/static/chunks/989-a7f752480eaaa2dc.js +0 -1
- package/admin-out/_next/static/chunks/app/page-8173112f256bc483.js +0 -1
- package/admin-out/_next/static/chunks/webpack-dc73047e0279979b.js +0 -1
- package/admin-out/_next/static/css/981c9e5610ce535a.css +0 -1
- /package/admin-out/_next/static/{_F_Fkyh9PainUNY82EEjY → j6buZnQDPISEFgtAuTGyl}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{_F_Fkyh9PainUNY82EEjY → j6buZnQDPISEFgtAuTGyl}/_ssgManifest.js +0 -0
|
@@ -29,6 +29,7 @@ Schedules are **durable** — the source of truth is the `cron_jobs` table; in-m
|
|
|
29
29
|
|
|
30
30
|
- **Recurring**: standard cron expression (`job.schedule`), optional `timezone`, optional **active window** `[activeFrom, activeUntil)` (epoch ms, `active_from` / `active_until`, NULL = unbounded; `CRON_MIGRATIONS` slot 2). The window is passed to croner as native `startAt` / `stopAt` (`activeWindowOptions` in `runner.ts`; `startAt` is `activeFrom − 1 ms` because croner's startAt is exclusive), so fires outside it simply never happen — no `cron_runs` row. "Pause until X" = push `activeFrom` to X; there is no skip-list for a gap in the middle. Past `activeUntil` the job stays `enabled` (so it can be extended later) but `scheduleJob` creates no croner and logs `past activeUntil; not scheduled` once — the id goes into `_expired` so reconcile's "fingerprint cached but not in `_active`" retry branch treats it as handled instead of re-scheduling (and re-logging) every 10s. The REST `nextRunAt` uses the same options, so the list shows the first post-pause fire; the admin badges a job "active from …" / "ended". Run-now ignores the window. Recurring only: a window together with `runAt` is rejected at every write surface (REST on the merged row, skill helper).
|
|
31
31
|
- **One-shot**: `job.runAt` (epoch ms) — croner fires once at that instant. After it completes, `finalize` sets `enabled=0` so it never re-fires. A `runAt` already in the past at schedule time is marked `lastRunStatus='missed'` and disabled (rather than firing immediately or retrying every reconcile). The `run_at` column is `CRON_MIGRATIONS` slot 0 in `db/cron-db.ts`, run through the shared `runMigrations` / `PRAGMA user_version` mechanism (see [storage.md](storage.md#schema-change-rules)).
|
|
32
|
+
- **Folder + stateless** (`CRON_MIGRATIONS` slot 4, v5): `cron_jobs.working_dir` — the folder the run focuses on, passed as `halo cli --dir` (same semantics as a sub-agent's `working_dir`: the folder's `.halo/INSTRUCTIONS.md` chain joins the prompt, plus a `Working directory:` line); stored workspace-relative, NULL = the workspace root; validated at every write surface (inside the workspace, existing — REST re-checks a stored folder when `workspacePath` moves). `cron_jobs.stateless` (0/1) — **new session every run**: each fire gets a fresh root session `cron-<id>-<base36 ms>` (`perRunSessionId`, same no-double-prefix rule as `effectiveSessionId`), so nothing accumulates across fires; mutually exclusive with `session_id` (`400` on the merged row). `cron_runs.session_id` — the session that run used, the only record of a stateless run's id (NULL on rows from before v5).
|
|
32
33
|
|
|
33
34
|
### Trigger-mode exclusivity (the db contract)
|
|
34
35
|
|
|
@@ -44,28 +45,38 @@ Exactly **one** of `schedule` / `runAt` is set per job. `runner.ts` `scheduleJob
|
|
|
44
45
|
REST route mutations call `scheduleJob` / `unscheduleJob` directly. But the `cron` skill (and manual ops) edit `cron.db` over a *different* sqlite connection. `reconcileFromDb` (10s timer) catches those:
|
|
45
46
|
|
|
46
47
|
- Fast path: `PRAGMA data_version` flips only when *another* connection commits. If unchanged since last pass, skip the full select entirely — one pragma read (~µs) vs. a select-all + per-row fingerprint compare. The pragma (and the run-pruning `NOT IN (subquery)` below) needs the raw better-sqlite3 handle, which the runner gets from the shared `rawSqlite(db)` helper (`db/raw-sqlite.ts`) instead of re-spelling drizzle's `$client` / `.session.client` cast at each site — it did, three times, each with its own hand-written method shape.
|
|
47
|
-
- On change: diff db rows against the in-memory `_fingerprint` map (`enabled
|
|
48
|
+
- On change: diff db rows against the in-memory `_fingerprint` map (`jobFingerprint`: enabled, schedule, runAt, timezone, activeFrom, activeUntil, agentId, userPrompt, workingDir, stateless); schedule new rows, unschedule deleted ones, re-instantiate croner only for rows whose fingerprint changed. Broadcasts a coalesced `cron:job_changed` per affected job.
|
|
48
49
|
|
|
49
50
|
## Execution (`runJob`)
|
|
50
51
|
|
|
51
52
|
Each fire:
|
|
52
53
|
|
|
53
|
-
1. **Concurrency guard**: resolve the job's session first (`effectiveSessionId` — `job.sessionId`, else the job's own session, see step 4), then check three conditions in order; the first hit inserts a `cron_runs` row with `status='skipped'` and that `failureReason`, broadcasts, and bails without spawning. Applies to both scheduled fires and manual run-now clicks.
|
|
54
|
+
1. **Concurrency guard**: resolve the job's session first (`effectiveSessionId` — `job.sessionId`, else the job's own session, see step 4; a stateless job's fresh `perRunSessionId`, which the two session-level checks never hit), then check three conditions in order; the first hit inserts a `cron_runs` row with `status='skipped'` and that `failureReason`, broadcasts, and bails without spawning. Applies to both scheduled fires and manual run-now clicks.
|
|
54
55
|
- Same `jobId` already running in this process (in-memory `_inflight` set) → `previous run still in progress`.
|
|
55
56
|
- Another job's cli already on the same session (`_inflightSessions`, keyed `realpath(workspace) + sessionId` so two spellings of one workspace collide) → `session <id> busy: another cron run is in progress`. Two jobs may pick the same session.
|
|
56
57
|
- The server itself is mid-turn anywhere in that session's tree (`hasActiveWorkInTree` on the workspace's live SessionManager, looked up with the registry's `peek` — cache-only, never builds a manager, whose constructor would run the boot reconcile as a side effect) → `session <id> busy: a turn is running in it`. In practice this is a picked session someone is chatting in.
|
|
57
58
|
|
|
58
59
|
Two overlapping writers would double-write the same on-disk session state; SessionManager's per-session lock is in-process and can't see across cli children, so these sets are the cheapest place to enforce serialization. They are in-memory, but their restart blind spot is covered: the boot-time orphan sweep (below) re-enters stale jobIds and session keys before croner re-arms. The runner reaches the server's managers through `setCronSessionRegistry(registry)` (called in `index.ts` next to `setRelayRegistry`); unset — tests, a bare CLI — means there are no server-side sessions to guard.
|
|
59
|
-
2. Insert a `cron_runs` row with `status='running'` up front (UI sees it mid-flight); broadcast `cron:run_changed`.
|
|
60
|
-
3. Check `job.workspacePath` exists — fail loudly if the workspace was moved/deleted.
|
|
61
|
-
4. Spawn `halo cli -a <agent> -s <session> -w <workspace> [--access <level>]`, prompt on **stdin** (avoids Windows argv length limits), with a fixed `[Unattended cron run — nobody will answer questions …]` line stamped ahead of `user_prompt` on every fire (`UNATTENDED_PREAMBLE` in `runner.ts`) — the model can't see the `cron-*` session id, and prompts typed into the admin Cron form carry no such notice, so the runner adds it rather than trusting the prompt author. **Session strategy**: every job runs in one stable session — its own by default, or an existing **root** session picked on the job (`cron_jobs.session_id`, nullable; `CRON_MIGRATIONS` slot 1 in `db/cron-db.ts`). The job's own session id **is the job id** (generated ids are already `cron-<ts>-<rand>`; a custom id without that prefix gets `cron-<id>`) — `effectiveSessionId` in `runner.ts` is the one copy of the rule, and the REST list returns its result as `effectiveSessionId` so the admin never re-derives it. Older builds always prefixed, giving `cron-cron-…`; `CRON_MIGRATIONS` slot 3 (v4) pins every job that ever spawned a cli (`last_run_id` set, or a non-skipped run row — covers a first run in flight across the upgrade restart) to that session via `session_id`, so its history and the orphan sweep's `-s` fingerprint stay the same; jobs that never ran take the new rule. The cli's `-s` is create-on-missing, so the first fire creates the session with the job's agent; every later fire *resumes the same session
|
|
60
|
+
2. Insert a `cron_runs` row with `status='running'` up front (UI sees it mid-flight), recording the session it runs in (`cron_runs.session_id`); broadcast `cron:run_changed`.
|
|
61
|
+
3. Check `job.workspacePath` exists — fail loudly if the workspace was moved/deleted. Same for the job folder (`job.workingDir`, re-resolved with `resolveWorkingDir`): moved / deleted since the job was saved → `failed` with `folder not found: …`, nothing spawned.
|
|
62
|
+
4. Spawn `halo cli -a <agent> -s <session> -w <workspace> --dir=<folder|.> [--access <level>]`, prompt on **stdin** (avoids Windows argv length limits), with a fixed `[Unattended cron run — nobody will answer questions …]` line stamped ahead of `user_prompt` on every fire (`UNATTENDED_PREAMBLE` in `runner.ts`) — the model can't see the `cron-*` session id, and prompts typed into the admin Cron form carry no such notice, so the runner adds it rather than trusting the prompt author. **Session strategy**: every job runs in one stable session — its own by default, or an existing **root** session picked on the job (`cron_jobs.session_id`, nullable; `CRON_MIGRATIONS` slot 1 in `db/cron-db.ts`). The job's own session id **is the job id** (generated ids are already `cron-<ts>-<rand>`; a custom id without that prefix gets `cron-<id>`) — `effectiveSessionId` in `runner.ts` is the one copy of the rule, and the REST list returns its result as `effectiveSessionId` so the admin never re-derives it. Older builds always prefixed, giving `cron-cron-…`; `CRON_MIGRATIONS` slot 3 (v4) pins every job that ever spawned a cli (`last_run_id` set, or a non-skipped run row — covers a first run in flight across the upgrade restart) to that session via `session_id`, so its history and the orphan sweep's `-s` fingerprint stay the same; jobs that never ran take the new rule. The cli's `-s` is create-on-missing, so the first fire creates the session with the job's agent; every later fire *resumes the same session*, so the conversation accumulates and is reviewable in the admin Sessions tab. A **stateless** job (`cron_jobs.stateless`) skips all of this: every fire creates a fresh session (`perRunSessionId`), recorded on the run row.
|
|
63
|
+
|
|
64
|
+
**Agent lock.** The runner always passes `-a <job agent>`, and the cli refuses `-s <existing>` with a different explicit `-a` (a session's agent never changes). So once a stateful job's session exists, the job's agent is fixed to the session's: the REST create / update routes return `400 session <id> already runs agent <x>; …` when the **merged** row's agent differs from the session's (`sessionAgentConflict`; the skill helper exits with the same message). The check only runs when the body actually changes one of its inputs — agent, session, stateless or workspace, compared as stored values (the admin form resends every field) — so a job saved before the rule, or hand-edited into a mismatch, still takes unrelated edits such as `{enabled:false}`; disabling is always allowed. Such a legacy mismatch is not repaired: each of its runs fails with the cli's refusal on stderr as `failureReason`, until the agent is changed back, the session deleted, or the job switched to stateless (which has no lock). The list GET returns the session's agent as `sessionAgentId` (null: stateless / session not created yet), which the admin form locks the agent field on, or flags with an amber notice when it already differs.
|
|
65
|
+
|
|
66
|
+
**Folder.** `--dir=<folder>` is always passed, as **one** token (a folder starting with `-` would otherwise read as a flag), with `.` for the workspace root, so the cli applies it on create *and* re-points a resumed session in place (`SessionManager.setWorkingDir`) — changing a job's folder moves its existing session too. The log header records `dir=`.
|
|
67
|
+
|
|
68
|
+
**Access level.** For a session that already exists the runner reads its `access_level` from the workspace db (`readSessionAccessLevel`, null column = `full`) and passes it as `--access`: the cli defaults to `full` and persists any level that differs from the row, so without it one cron fire would permanently promote a `readonly` / `workspace` session. A session the cli is about to create gets no `--access` (full, as before). The log header records `session=` (with `(stateless)`) / `access=`.
|
|
69
|
+
|
|
70
|
+
**Session lookup** (`readSessionRow`, shared by the access level, `sessionAgentId` and the agent lock). It runs per job on every list GET and `cron:job_changed` push, so it never goes through `getWorkspaceDb` (realpath + `ensureWorkspaceHalo` scaffolding per call — and a GET must not scaffold): one read-only better-sqlite3 handle per workspace path, with the rows cached and dropped whenever `PRAGMA data_version` moves. The handle never writes, so any commit by anyone — the cli child creating the session (`finalize` reads it right after), a Sessions-tab delete, another process — moves the version. **Absent vs unreadable**: no `.halo/halo.db` or no row → null ("no session yet"); any other failure (corrupt / locked db) throws `could not read session <id> from <ws>: …`, so a read error never passes for "no session" — which would spawn a restricted session at `full`, or let the agent lock through. At fire time the run is finalized `failed` with that message and nothing is spawned; the REST write points return it as the `400` (the skill helper reads an unreadable db as no session — the fire still refuses); display (GET `sessionAgentId`, `cron:job_changed`) reads it as null.
|
|
71
|
+
|
|
72
|
+
The child's pid is written back onto the `running` row (`cron_runs.pid`, nullable) — this is what makes the orphan sweep possible after a server crash. Note the **dual-writer layout** this creates: the cli child and the server share the workspace's session files. The server side is view-only for these sessions — admin subscribes seed a UIState from disk, and the WS detach-save skips it via the dirty gate (see [session.md](session.md#ws-disconnect-resilience)); without that gate a frozen server snapshot would overwrite the cli child's newer messages (the cron-session UI-log truncation incident). Live streaming of a cli-driven session into the admin is out of scope — events don't cross processes; the Sessions tab re-reads the file on open.
|
|
62
73
|
|
|
63
74
|
A **picked** session breaks the view-only assumption: the server may hold a live in-memory copy (it was chatted in / opened before, and `ensureSession` keeps it resident), and its raw history predates the cli's turn — the next server-side save would write the cron turn away. So once the cli exits the runner re-`peek`s the workspace's manager (it may have been built mid-run) and calls `SessionManager.forgetExternalWrite(sessionId)`: if the tree is idle, it drops the root + `root>…` sessions from `sessions` **without saving** and drops the UIState, so the next view / message re-reads disk. If a turn started in the session while the cli ran (step 1 only covers turns already running at spawn), it returns false and nothing is dropped — both processes wrote the same files, the last save wins, and the runner logs a `console.warn` that one of the two turns may be missing. That window is the accepted cost of sharing a session with a job; the admin form hint and the `cron` skill both say so.
|
|
64
75
|
5. Tee stdout + stderr live to `~/.halo/global/logs/cron/<runId>.log`; capture stdout to memory for dispatch. Since 1.1.5 the cli's stdout is the **final reply of the last root turn** as raw markdown (stdout is not a TTY, so no terminal styling; the interim text the model emits before tool calls and the wrap-ups of earlier drained turns are not included — see [session.md](session.md#message-queue-and-drain) "Final-text flag"). Before that it was every streamed text block of the whole run, which for a director session absorbing a dozen sub-agent reports reached ~40 KB and blew the wechat gateway ceiling.
|
|
65
76
|
6. Enforce a **per-job timeout** (`job.timeoutSec ?? 3600`, the nullable `cron_jobs.timeout_sec` validated 60–21600 at every write surface; a change takes effect on the job's next fire, no croner re-instantiation needed) via a Node timer (cross-platform, not the Linux-only `timeout(1)`), with a **two-phase kill**: SIGTERM the direct child first — the cli has a SIGTERM handler that stops the harness, repairs + flushes the session, kills its own shell_exec process groups, and exits 143 — then, if it's still alive after a 30s grace, SIGKILL the entire descendant tree (POSIX ppid-walk over one `ps` snapshot; `taskkill /T /F` on Windows) so no grandchild survives to double-write the workspace on the next fire. A ppid walk, not a process-group kill: the cli sandbox spawns shell_exec workers `detached` into their own groups, so `kill(-pid)` would miss them. Timed-out runs still report exit code 124. Generous because overlap-of-the-same-job is already blocked up-front in step 1; this is just the long-stop reaper for a truly stuck child.
|
|
66
77
|
7. Classify: exit 124 → `timeout`; non-zero → `failed`; zero but empty stdout → `failed` ("nothing to dispatch" — with the final-only stdout contract this means the last turn produced no wrap-up text, not that the agent never spoke); else `succeeded`.
|
|
67
78
|
8. On success only, `dispatchToTargets(stdout, targets, job.workspacePath)` — the raw stdout goes down as-is; `MEDIA:` extraction happens per-target inside the dispatcher (see Dispatch model). If every target fails, downgrade to `failed`.
|
|
68
|
-
9. `finalize`: write the terminal `cron_runs` row, update `cron_jobs.lastRun*`, disable one-shot jobs, broadcast. The `_inflight` and `_inflightSessions` entries are released in `finally`, regardless of outcome.
|
|
79
|
+
9. `finalize`: write the terminal `cron_runs` row, update `cron_jobs.lastRun*`, disable one-shot jobs, broadcast. The `cron:job_changed` it sends carries `workingDir` / `stateless` / `sessionAgentId` (`jobChangedFields`, as do the REST mutations' and reconcile's) — `sessionAgentId` read after the run, so a first fire's push already has the agent of the session it just created. The `_inflight` and `_inflightSessions` entries are released in `finally`, regardless of outcome.
|
|
69
80
|
|
|
70
81
|
The cli executable is resolved via `resolveHaloCli()` (`$HALO_CLI` override; `halo.cmd` on Windows to dodge the GUI `Halo.exe` on PATH).
|
|
71
82
|
|
|
@@ -75,8 +86,8 @@ The step-1 guard is per-process, so it has one blind spot: the server dies (SIGK
|
|
|
75
86
|
|
|
76
87
|
`startCronDaemon` calls `sweepOrphanRuns` **before** `reloadAll`. For each stale row:
|
|
77
88
|
|
|
78
|
-
1.
|
|
79
|
-
2. If the row has a pid (POSIX): **identity-check before killing** — `ps -p <pid> -o args=` must carry the session id as a whole argv token (the `-s <session>` value; whole-token so `cron-a` never matches `cron-ab`), so a recycled pid never kills an innocent process. If the job
|
|
89
|
+
1. Take the session the orphan was spawned on from the run row (`cron_runs.session_id` — the only source for a stateless job's per-run id; a row from before that column falls back to the job row's effective session, a job deleted since to the default rule on its id), then re-enter its jobId into `_inflight` and its session key into `_inflightSessions` first — the core invariant: while the orphan may be alive, no new cli for that job *or for any other job on that session* is spawned (a fire in that window is recorded as `skipped`, same as normal overlap).
|
|
90
|
+
2. If the row has a pid (POSIX): **identity-check before killing** — `ps -p <pid> -o args=` must carry the session id as a whole argv token (the `-s <session>` value; whole-token so `cron-a` never matches `cron-ab`), so a recycled pid never kills an innocent process. If the session came from the job row (pre-v5 run row) and was re-pointed after the orphan was spawned, the fingerprint won't match and the orphan is left alone — the safe direction. Verified → SIGTERM (graceful path, same as timeout phase 1); still alive after a 30s async grace (`unref`ed timer, identity re-checked — pid reuse again) → SIGKILL the descendant tree.
|
|
80
91
|
3. No pid / already dead / fingerprint mismatch / Windows: no kill, log only. Windows deliberately marks-only — there's no cheap command-line identity check (WMI is slow and sometimes policy-blocked), and a full-tree `taskkill /T /F` on a reused pid is irreversible while a double-written session is fixable; the orphan-survives-server-death window is also naturally smaller there.
|
|
81
92
|
4. Either way the row is finalized as `failed` (`failureReason` notes the orphan disposition, `completedAt` stamped) so the UI stops showing a ghost `running`, and the jobId / session key are released once the orphan is confirmed dead (or immediately when nothing was killable).
|
|
82
93
|
|
|
@@ -112,7 +123,7 @@ interface CronChannelDispatcher {
|
|
|
112
123
|
|
|
113
124
|
## Key files
|
|
114
125
|
|
|
115
|
-
- `packages/server/src/cron/runner.ts` — scheduler: croner lifecycle, `runJob` spawn/capture/finalize, reconcile, retention
|
|
126
|
+
- `packages/server/src/cron/runner.ts` — scheduler: croner lifecycle, `runJob` spawn/capture/finalize, reconcile, retention; `effectiveSessionId` / `perRunSessionId`, the read-only session lookup (`readSessionRow`) behind `sessionAgentIdFor` / `sessionAgentConflict`.
|
|
116
127
|
- `packages/server/src/cron/dispatcher.ts` — channel-agnostic dispatch registry (`registerCronDispatcher`, `dispatchToTargets`, `listAllCronTargets`).
|
|
117
128
|
- `packages/server/src/db/cron-db.ts` — `cron_jobs` + `cron_runs` schema, `CRON_MIGRATIONS`, `getCronDb`.
|
|
118
129
|
- `packages/server/src/agents/session-manager.ts` — `forgetExternalWrite` (drop the stale in-memory copy of a session a cli child just wrote).
|
|
@@ -121,6 +132,6 @@ interface CronChannelDispatcher {
|
|
|
121
132
|
|
|
122
133
|
## Scope
|
|
123
134
|
|
|
124
|
-
Supported: recurring (cron expr) + one-shot (`runAt`) schedules; per-timezone; optional active window for recurring jobs (`activeFrom` / `activeUntil` — "pause until", "send until"); per-job configurable timeout (`timeoutSec`, 60–21600s, default 3600); multi-target fan-out; stable accumulating session per job — its own (id = job id) or a picked existing root session (`sessionId`); run-now (manual trigger); admin UI history with per-run logs + dispatch results.
|
|
135
|
+
Supported: recurring (cron expr) + one-shot (`runAt`) schedules; per-timezone; optional active window for recurring jobs (`activeFrom` / `activeUntil` — "pause until", "send until"); per-job configurable timeout (`timeoutSec`, 60–21600s, default 3600); multi-target fan-out; stable accumulating session per job — its own (id = job id) or a picked existing root session (`sessionId`) — or a fresh session every run (`stateless`); a per-job folder (`workingDir`, the run's `--dir`); run-now (manual trigger); admin UI history with per-run logs + dispatch results.
|
|
125
136
|
|
|
126
|
-
Not supported: sub-minute schedules below croner's resolution; in-process job execution (always a fresh cli child); running in a sub-session (`>` paths — root sessions only); a conflict-free shared session — a message sent into a picked session while its cron run is in flight can lose one of the two turns (logged, not prevented; see step 4).
|
|
137
|
+
Not supported: sub-minute schedules below croner's resolution; in-process job execution (always a fresh cli child); running in a sub-session (`>` paths — root sessions only); changing a stateful job's agent once its session exists (agent lock); a conflict-free shared session — a message sent into a picked session while its cron run is in flight can lose one of the two turns (logged, not prevented; see step 4).
|
|
@@ -29,7 +29,7 @@ Entry point: `session-agent-builder.ts` `SessionAgentBuilder.buildAgentInstance(
|
|
|
29
29
|
├── USER.md ← optional, overrides global USER.md
|
|
30
30
|
├── INSTRUCTIONS.md ← project-level (overrides global INSTRUCTIONS.md)
|
|
31
31
|
├── INDEX.md ← project overview + doc index
|
|
32
|
-
├── <subdir>/.halo/INSTRUCTIONS.md ← directory-scoped: baked into the system prompt when a sub-agent's
|
|
32
|
+
├── <subdir>/.halo/INSTRUCTIONS.md ← directory-scoped: baked into the system prompt when a session's working_dir (sub-agent, or a root's --dir / cron folder) points here; injected per-turn via user @scope otherwise
|
|
33
33
|
├── prompts/ ← optional, directory-level override of global prompts/
|
|
34
34
|
│ ├── bootstrap/ ← overrides global prompts/bootstrap/ if dir exists
|
|
35
35
|
│ ├── all/ ← overrides global prompts/all/ if dir exists
|
|
@@ -78,7 +78,7 @@ agent's AGENT.md), because the workspace folder replaces the global one whole.
|
|
|
78
78
|
**Override rule.** The workspace-root INSTRUCTIONS.md suppresses the global one (same override as USER.md / AGENT.md), so a cloned workspace is self-contained — its conventions travel with the repo and don't depend on the machine's global file. When the workspace root has no INSTRUCTIONS.md, the global one is used.
|
|
79
79
|
|
|
80
80
|
**Sub-directory INSTRUCTIONS.md — two paths, by who supplies the directory** (see [Directory-scoped instructions](#directory-scoped-instructions-scope) below):
|
|
81
|
-
- **A sub-agent's `
|
|
81
|
+
- **A session's `working_dir`** — a sub-agent's from `start_session`, or a root session's from `halo cli` / `halo tui --dir` or a cron job's folder — is persistent session identity (stored in the DB, restored on resume), so its directory-chain INSTRUCTIONS.md are baked into the **system prompt every turn** — the agent never forgets the rules of the directory it lives in.
|
|
82
82
|
- **A user's `@scope <dir>`** is ad-hoc, so it's injected **per-turn** into that one message only (loop-scoped — relevant to the turn that asked for it, not a permanent part of identity).
|
|
83
83
|
|
|
84
84
|
### INDEX.md (**project root only**)
|
|
@@ -146,7 +146,7 @@ Missing directory or read failure: warn + use built-in fallback.
|
|
|
146
146
|
3. The live agent roster (`## Your Team`), slotted directly behind AGENT.md (see [Agent roster](#agent-roster) below). Empty string for non-delegating agents (no `team` / empty `team`) / internal agents, so the section is dropped.
|
|
147
147
|
4. `## User Instructions` — `~/.halo/global/INSTRUCTIONS.md` (suppressed when the workspace root has its own — see Step 2)
|
|
148
148
|
5. `## User Instructions` — `<ws>/.halo/INSTRUCTIONS.md` (workspace root).
|
|
149
|
-
6. `## User Instructions` —
|
|
149
|
+
6. `## User Instructions` — the session's `working_dir` directory-chain INSTRUCTIONS.md (`loadScopeBody`, plain markdown, headed `### <dir>`; a sub-agent's, or a root's `--dir` / cron folder), folded into the same region right after #4/#5 so the order reads general → specific. Empty when there's no working_dir / it's the root / it has no sub-dir file. (User `@scope` does NOT come through here — it injects per-turn into the message, wrapped in `<workspace-instructions>`.)
|
|
150
150
|
7. `## Project Knowledge` — `<ws>/.halo/INDEX.md` (skipped entirely when no INDEX.md exists)
|
|
151
151
|
|
|
152
152
|
Global and workspace-root INSTRUCTIONS share the same `## User Instructions` heading: they're mutually exclusive (workspace overrides global, see Step 2), so only one ever lands in a prompt and there's no sibling to disambiguate from. A sub-agent's `working_dir` directory-chain (#6) reuses the same heading and is a third, *additive* layer in that region — sub-dir rules add to, never replace, the global/ws-root base, so the order reads general → specific. The roster rides as section 3 — passing it through `composeMdPrompt` rather than concatenating it afterward gives it the same `---` separators as every other section (no glue-to-next-block).
|
|
@@ -163,7 +163,7 @@ mdPrompt ← incl. roster + working_dir
|
|
|
163
163
|
+ allPrompt
|
|
164
164
|
```
|
|
165
165
|
|
|
166
|
-
(The `working_dir` directory-chain INSTRUCTIONS.md live *inside* `mdPrompt` — folded into the `## User Instructions` region by `composeMdPrompt` — not after the `Working directory:` tagline. That tagline is just a one-line focus marker.)
|
|
166
|
+
(The `working_dir` directory-chain INSTRUCTIONS.md live *inside* `mdPrompt` — folded into the `## User Instructions` region by `composeMdPrompt` — not after the `Working directory:` tagline. That tagline is just a one-line focus marker, present when a root session has a folder — `halo cli` / `halo tui --dir`, a cron job's folder — other than the workspace root.)
|
|
167
167
|
|
|
168
168
|
The roster is computed once (`canDelegate(yaml)` → `buildAgentRoster(agentId, team)`, else `''`, where `canDelegate` is "not internal AND a non-empty `team`") and handed to `composeMdPrompt`, so it lands inside `mdPrompt` behind AGENT.md. Only when there's no AGENT.md at all (the fallback branch) is a non-empty roster appended at the tail instead — there's no MD layer to slot it behind.
|
|
169
169
|
|
|
@@ -194,6 +194,8 @@ If `mdPrompt` is empty:
|
|
|
194
194
|
- Use `yamlConfig.system_prompt` if present
|
|
195
195
|
- Otherwise use the hard-coded default string
|
|
196
196
|
|
|
197
|
+
This branch carries no `working_dir`: no `Working directory:` line, root or sub-agent (`--dir` / cron folder included). The folder's INSTRUCTIONS.md chain is part of `mdPrompt`, so the branch is only reached when that chain is empty too — an agent with a custom `system_prompt` and no AGENT.md whose folder *does* have a chain gets a non-empty `mdPrompt` and the normal branch above, where `system_prompt` is not used.
|
|
198
|
+
|
|
197
199
|
## Agent roster
|
|
198
200
|
|
|
199
201
|
`buildAgentRoster(selfAgentId, team)` ([session-agent-builder.ts](../../../packages/server/src/agents/session-agent-builder.ts)) builds a live team block listing the agents this session can delegate to — one `- \`<id>\` — <name>: <description>` line per teammate.
|
|
@@ -287,6 +289,7 @@ Shared core: [`loadScopeBody(workspaceRoot, relDir)`](../../../packages/server/s
|
|
|
287
289
|
| Trigger | Who | Lifetime | How it reaches the model |
|
|
288
290
|
|---|---|---|---|
|
|
289
291
|
| `working_dir` on `start_session` | parent agent | **persistent** — every turn, for the session's life | `composeMdPrompt` folds the **bare `loadScopeBody` markdown** into the `## User Instructions` region, right after the global/ws-root layer ([`session-agent-builder.composeSystemPrompt`](../../../packages/server/src/agents/session-agent-builder.ts)). No `<workspace-instructions>` wrapper — the section heading already frames it. `working_dir` is stored on `agent_sessions.working_dir` and restored on resume, so the rules are rebuilt into the system prompt on every `buildAgentInstance`. |
|
|
292
|
+
| `--dir` on `halo cli` / `halo tui`, a cron job's folder | user / cron runner | **persistent**, same as above | The root session's `agent_sessions.working_dir`, set on create and re-pointed in place on resume (`SessionManager.setWorkingDir`); same `composeMdPrompt` path. |
|
|
290
293
|
| `@scope <dir>` in a message | user (TUI / admin / channels) | **one-shot** — only the turn it rides on | `loadScopeInstructions` wraps the body in a self-describing `<workspace-instructions dir=... note=...>` block (it lands in the message stream, so it needs the tag + note to read as turn context); `SessionManager.expandScopeMarkers` strips the marker from the visible text and prepends the block to that message. Not re-injected on later turns. |
|
|
291
294
|
|
|
292
295
|
Why the split: `working_dir` is the sub-agent's persistent identity (it lives in that directory for its whole life), so its rules belong in the system prompt where they're present every turn — a first-turn-only injection would let the agent forget them after a few turns. `@scope` is ad-hoc context a user attaches to one message, so it stays loop-scoped. (The earlier `scope` args on `query_session` / `interrupt_session` were removed — they let a parent temporarily swap a child's directory rules, which contradicts working_dir being persistent identity.)
|
|
@@ -383,6 +386,8 @@ Schema declared by each package + values stored centrally is the same model VSCo
|
|
|
383
386
|
## working_dir behaviour
|
|
384
387
|
|
|
385
388
|
- `start_session` accepts a `working_dir` argument (absolute or workspace-relative)
|
|
389
|
+
- A root session takes one too: `halo cli` / `halo tui --dir <path>` (a cron job's folder rides on it), same checks; on a resumed session it re-points the stored value via `SessionManager.setWorkingDir` (`--dir .` = back to the root), refused while the session is running
|
|
390
|
+
- `/session new` (server-side `execNew`) gives the new session the folder of the issuing session's root, re-validated (gone → root)
|
|
386
391
|
- Default: `workspaceRoot`
|
|
387
392
|
- Enforced inside workspace (path traversal is rejected)
|
|
388
393
|
- Non-existent directory → spawn refused
|
|
@@ -394,7 +399,7 @@ Schema declared by each package + values stored centrally is the same model VSCo
|
|
|
394
399
|
|
|
395
400
|
- **Override precedence**: workspace > global (USER.md, AGENT.md, INSTRUCTIONS.md, prompts/)
|
|
396
401
|
- **agents/ & skills/ override**: whole-folder. `<ws>/.halo/agents/<id>/` (or `skills/<id>/`) exists → serves that agent/skill entirely (agent.yaml + AGENT.md, or SKILL.md + resources), global folder ignored, no per-file fallback
|
|
397
|
-
- **INSTRUCTIONS.md**: workspace root INSTRUCTIONS.md suppresses global; subdirectory chain stacks on top — in the system prompt every turn when it's a
|
|
402
|
+
- **INSTRUCTIONS.md**: workspace root INSTRUCTIONS.md suppresses global; subdirectory chain stacks on top — in the system prompt every turn when it's a session's `working_dir` (persistent; sub-agent, or a root's `--dir`), or per-turn in the message via user `@scope` (one-shot)
|
|
398
403
|
- **prompts/ override**: `<ws>/.halo/prompts/<scope>/` directory exists → entirely replaces global's same scope (directory-level, no per-file merge)
|
|
399
404
|
- **INDEX.md is project-root only**: subdirectory INDEX files are not auto-loaded
|
|
400
405
|
- **Root vs sub-agent**: determined by `!parentId`
|
|
@@ -83,6 +83,7 @@ Manages every agent session's lifecycle (root + sub-agent). Each session is 1:1
|
|
|
83
83
|
| Method | Purpose |
|
|
84
84
|
|---|---|
|
|
85
85
|
| `createSession(agentId, parentId, description, agentName?, explicitId?, workingDir?, accessLevel?, title?)` | Create a session (SQLite + memory). `workingDir` = absolute path at runtime, stored as workspace-relative in DB; null = project root. `accessLevel` = `'readonly'`, `'workspace'`, or `null` (full). |
|
|
86
|
+
| `setWorkingDir(sessionId, workingDir)` | Re-point a session's persisted `working_dir` (`--dir` on a resumed root session). `workingDir` absolute or null = root; returns `{from, to}` (workspace-relative) or null when it's already the target (no write). Refuses while the session is running; an idle in-memory copy is released so the next `ensureSession` rebuilds the prompt with the new folder |
|
|
86
87
|
| `sendUserMessage(sessionId, text, images?, accessLevel?)` | Send a message — run immediately if idle, queue if busy; returns `'running'` or `'queued'` |
|
|
87
88
|
| `compactSession(sessionId, opts?)` | LLM-summary compact (`opts.onProgress` for UI progress) |
|
|
88
89
|
| `interruptSession(sessionId)` | Abort the in-flight turn now (fire-and-forget); `interruptRequested` is set so the unwind repairs rather than errors, then the queued message drains. Shared by esc and the `interrupt_session` tool |
|
|
@@ -282,7 +283,7 @@ Table `agent_sessions` holds metadata only (no runtime state):
|
|
|
282
283
|
| agent_id | TEXT | Agent YAML ID |
|
|
283
284
|
| agent_name | TEXT | Display name |
|
|
284
285
|
| description | TEXT | Task description |
|
|
285
|
-
| working_dir | TEXT | Workspace-relative path; null = project root |
|
|
286
|
+
| working_dir | TEXT | Workspace-relative path; null = project root. A sub-agent's `working_dir` from `start_session`, or a root session's folder (`halo cli` / `halo tui --dir`, a cron job's folder) — set on create, re-pointed in place on resume by `setWorkingDir` |
|
|
286
287
|
| access_level | TEXT | `'readonly'`, `'workspace'`, or null; null = full access |
|
|
287
288
|
| created_at / updated_at | INTEGER | Epoch ms |
|
|
288
289
|
| stopped_at | INTEGER | null = active |
|
|
@@ -351,6 +352,8 @@ In the admin, `/session new` (also `/clear`, "+ New Session") does not touch the
|
|
|
351
352
|
|
|
352
353
|
Before 1.5.3-alpha this was the WS `session:clear` frame (save, release the listener, unbind the connection); it was removed when one connection started carrying every open tab. Other channels' `/session new` still creates the session server-side and returns `switchTo`.
|
|
353
354
|
|
|
355
|
+
That server-side `execNew` (`channels/shared/commands.ts`) keeps the folder: the new session inherits the `working_dir` of the **root** of the session the command was issued in — `ctx.sessionId` when the caller passes it (the TUI harness does, so a sub-agent tab resolves to its root), else the channel's active session (`findActiveSessionId`). The folder is re-validated with `resolveWorkingDir`; one that no longer exists starts the new session at the workspace root, with a `[Commands] /new: folder of <root> not kept, …` log line.
|
|
356
|
+
|
|
354
357
|
See [background-dispatch.md](background-dispatch.md).
|
|
355
358
|
|
|
356
359
|
## WS disconnect resilience
|
|
@@ -26,7 +26,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
26
26
|
│ │ # — global so they don't pollute any workspace's halo.db
|
|
27
27
|
│ │ └── <sessionId>.json
|
|
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)
|
|
29
|
+
│ ├── cron.db # Cross-workspace cron jobs + run history (cron_jobs + cron_runs; 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)
|
|
@@ -434,6 +434,8 @@ Paths: `~/.halo/global/models/<providerId>.yaml` (bundled, seeded from `template
|
|
|
434
434
|
|
|
435
435
|
**Merge rule** (one implementation, `packages/server/src/models/registry.ts` `loadProviders`, behind the server registry, the Settings secrets forms, `halo setup` and the hub installer): per provider `id` the copy with the higher `revision` wins; on a tie the `models.d` copy wins; a yaml without `revision` counts as 0 (users' own custom yamls in `models/`). A `models.d` copy whose `runtime:` this build doesn't know is skipped with a `[Config]` log, so the bundled copy stays in effect. `halo setup` merges the package's `templates/models/` with `models.d/` the same way.
|
|
436
436
|
|
|
437
|
+
**Hub install** (`halo models install <dir> [--yes]`, `models/install.ts`): plan, then write `models.d/<id>.yaml` via tmp + rename. A yaml is refused (others still go ahead) for an unknown `runtime:` ("needs a newer halo"), a missing / non-integer `revision`, a `secrets[].default` other than empty / `<<ENV_NAME>>`, or bad model fields — `contextWindow` / `maxContextWindow` not positive integers, `contextWindow` > `maxContextWindow`, `compressAt` outside (0, 1], `endpoints` not a list of `https://` URLs (reason shaped `models.<id>.<field> …`). An older revision than the copy in effect is skipped, an identical one is up to date. **Consent**: the whole run writes nothing and needs `--yes` (exit 3) when an installed provider is new, its `defaultEndpoint` / `endpointPresets` change, or a model's `endpoints:` gains a URL the provider didn't use anywhere before (not in its old `defaultEndpoint`, presets or any model's list). Removed URLs, and added ones the provider already used, are listed but need no consent.
|
|
438
|
+
|
|
437
439
|
**Live reload**: `models/watcher.ts` watches `models.d/` (non-recursive, 300 ms debounce; created at boot), drops the registry cache and broadcasts `models:changed` when the merged view changed; admin pages re-fetch `/agent-configs/models` and the settings schema. `models/` isn't watched — only template refresh writes it, before the registry is first read.
|
|
438
440
|
|
|
439
441
|
```yaml
|
|
@@ -445,11 +447,11 @@ displayName: AWS Bedrock Claude (Invoke API) # shown in UI
|
|
|
445
447
|
displayName_zh: AWS Bedrock Claude(Invoke API) # optional zh override
|
|
446
448
|
description: Invokes Bedrock via InvokeModel (non-streaming)
|
|
447
449
|
description_zh: 通过 Bedrock InvokeModel(非流式)调用
|
|
448
|
-
defaultEndpoint: https://bedrock-runtime.us-
|
|
450
|
+
defaultEndpoint: https://bedrock-runtime.us-east-1.amazonaws.com
|
|
449
451
|
defaultModelId: global.anthropic.claude-sonnet-4-6 # picked when user switches to this provider
|
|
450
|
-
endpointPresets:
|
|
451
|
-
- https://bedrock-runtime.us-west-2.amazonaws.com
|
|
452
|
+
endpointPresets: # endpoint dropdown for models without their own `endpoints:`
|
|
452
453
|
- https://bedrock-runtime.us-east-1.amazonaws.com
|
|
454
|
+
- https://bedrock-runtime.us-west-2.amazonaws.com
|
|
453
455
|
|
|
454
456
|
# Server-side credentials. Stored at <provider-id>.secrets.<key> in
|
|
455
457
|
# settings.yaml; never injected into agent prompts or shell_exec.
|
|
@@ -466,7 +468,11 @@ secrets:
|
|
|
466
468
|
models:
|
|
467
469
|
- id: global.anthropic.claude-sonnet-4-6
|
|
468
470
|
displayName: Claude Sonnet 4.6
|
|
471
|
+
# endpoints: [https://…us-east-1…] # optional — only when the model is served on a subset; narrows endpointPresets
|
|
469
472
|
maxOutputTokens: 64000
|
|
473
|
+
contextWindow: 272000 # recommended window for a new agent (≤ 272000, ≤ maxContextWindow)
|
|
474
|
+
maxContextWindow: 1000000 # the model's true limit; absent → contextWindow
|
|
475
|
+
compressAt: 0.9 # compaction ratio for a new agent, (0, 1]
|
|
470
476
|
capabilities:
|
|
471
477
|
image: true # supports image input
|
|
472
478
|
video: false
|
|
@@ -486,18 +492,33 @@ models:
|
|
|
486
492
|
- { value: medium, label: Medium }
|
|
487
493
|
- { value: high, label: High }
|
|
488
494
|
- { value: max, label: Max }
|
|
495
|
+
verbosity: # Responses-API providers only (bedrock-mantle runtime)
|
|
496
|
+
default: medium
|
|
497
|
+
presets:
|
|
498
|
+
- { value: low, label: Low }
|
|
499
|
+
- { value: medium, label: Medium }
|
|
500
|
+
- { value: high, label: High }
|
|
489
501
|
```
|
|
490
502
|
|
|
503
|
+
**Per-model `endpoints:`** — `resolveModelEndpoints(provider, model)` (`models/model-endpoints.ts`; the admin form keeps a byte-identical copy in `features/agents/model-endpoints.ts`, pinned by `test/model-endpoints.test.ts`): a non-empty list is authoritative for that model — it replaces `endpointPresets` in the dropdown, and the model's default endpoint is `defaultEndpoint` when the list contains it, else the list's first entry. No list → provider presets and `defaultEndpoint`. Used by scaffolding / `halo setup` (`modelBlockFromProvider`) and the agent form. Runtime never falls back: a session needs `model.endpoint` in agent.yaml.
|
|
504
|
+
|
|
505
|
+
**Context fields** (`resolveModelContext` / `resolveEffectiveContext`, `config.ts`): `contextWindow` is the *recommended* window, kept ≤ 272000 even for 1M-class models (input past 272K bills at long-context rates on most of them); `maxContextWindow` is the true limit (absent → `contextWindow`); `compressAt` is the model's compaction ratio. At runtime a session's budget is agent.yaml `context.maxTokens` / `compressAt`, else the global defaults — `config.model.maxContextTokens` (272000) capped at the model's max, and `general.compact.compress_at` (0.9) — then `maxTokens` is clamped to the model's max (warned, `[SessionAgentBuilder] … clamped`). Same resolution for live sessions (`buildModelRuntime`) and cold ones (`getContextConfig`). `PUT /agent-configs/:id/yaml` refuses a new `context.maxTokens` above the model's max (400); an over-max value already on disk for the same model stays editable.
|
|
506
|
+
|
|
507
|
+
**Scaffold-only defaults** — the registry's recommended `contextWindow` / `compressAt` and `capabilities.verbosity.default` are written into agent.yaml when an agent is created (new-agent scaffold, `halo setup` rebind, the form's model / provider switch); runtime never reads them for an existing agent, so a registry update can't move a running agent. A new agent's `context:` = `contextWindow` (else the runtime default) and compressAt = explicitly set global `compact.compress_at` > model `compressAt` > 0.9 (`explicitCompressAt()`, `agent-configs.ts` `scaffoldContext`; settings.yaml only holds keys a user wrote, so absent = never set). Verbosity at runtime comes only from agent.yaml `model.verbosity`; absent → MantleAgent sends `low`.
|
|
508
|
+
|
|
491
509
|
### Capability defaults — what each `default*` field controls
|
|
492
510
|
|
|
493
511
|
| Path | Used when | Fallback |
|
|
494
512
|
|---|---|---|
|
|
495
513
|
| `<provider>.defaultModelId` | User switches provider; form picks this model | `models[0].id` |
|
|
496
|
-
| `<provider>.defaultEndpoint` | Same; endpoint input | `''` |
|
|
514
|
+
| `<provider>.defaultEndpoint` | Same; endpoint input (narrowed by the model's `endpoints:`, see above) | `''` |
|
|
515
|
+
| `models[].contextWindow` | New agent / model switch with an untouched or over-max window → `context.maxTokens` | runtime default (272000 capped at max) |
|
|
516
|
+
| `models[].compressAt` | New agent (unless the global `compress_at` is set) / model switch with an untouched ratio → `context.compressAt` | global `compress_at`, 0.9 |
|
|
497
517
|
| `capabilities.promptCaching.default` | Toggle on / provider switch into a caching-capable model | `ttlPresets[0].value` |
|
|
498
518
|
| `capabilities.thinking.defaultEnabled` | New agent or provider switch with no prior thinking config | `false` |
|
|
499
519
|
| `capabilities.thinking.default` | Thinking toggled on, adaptive mode | `effortPresets[0].value` |
|
|
500
520
|
| `capabilities.thinking.defaultBudgetTokens` | Thinking toggled on, manual mode | `8192` |
|
|
521
|
+
| `capabilities.verbosity.default` | New agent / provider or model switch with no valid prior value → `model.verbosity` | none written; runtime `low` |
|
|
501
522
|
|
|
502
523
|
Existing agent yaml values always win over these defaults — switching providers carries the user's effort/ttl over when valid for the new model. The defaults only fire when there's nothing to carry, or the carry-over isn't valid in the new vocabulary (e.g. Bedrock `medium` → Kimi `enabled/disabled` mismatch resets to the new provider's `default`).
|
|
503
524
|
|
|
@@ -565,7 +586,7 @@ interface SkillMeta {
|
|
|
565
586
|
}
|
|
566
587
|
```
|
|
567
588
|
|
|
568
|
-
Load precedence
|
|
589
|
+
Load precedence: workspace > global > extension, **whole-folder override** (the agent rule, plus a third, read-only source: `~/.halo/global/extensions/<extId>/skills/<id>/`, see [canvas-extensions.md → Extension skills](canvas-extensions.md#extension-skills)). A higher `skills/<id>/` folder replaces the lower skill wholesale — SKILL.md plus every sibling resource file — with no per-file fallback.
|
|
569
590
|
|
|
570
591
|
## SQLite databases
|
|
571
592
|
|
|
@@ -607,7 +628,7 @@ The last four are a **read cache for the session list** — `GET /api/sessions/l
|
|
|
607
628
|
|---|---|---|
|
|
608
629
|
| item_type | TEXT NOT NULL | `'agent'` or `'skill'` |
|
|
609
630
|
| item_id | TEXT NOT NULL | Agent/skill ID |
|
|
610
|
-
| scope | TEXT NOT NULL | `'global'` or `'
|
|
631
|
+
| scope | TEXT NOT NULL | `'global'`, `'workspace'` or `'extension'` (a skill shipped by an installed extension, `agents/skill-sources.ts`) — which source the item comes from. `'extension'` added by `HALO_MIGRATIONS` v2 (table rebuild, rows kept) |
|
|
611
632
|
| disabled_at | INTEGER NOT NULL | Unix ms |
|
|
612
633
|
| | | **PK**: `(item_type, item_id, scope)` |
|
|
613
634
|
|
|
@@ -642,7 +663,8 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
|
|
|
642
663
|
|
|
643
664
|
- `schema.sql` / each db's `CREATE_SQL` always describes the FULL current shape, so a fresh db is complete after the CREATEs alone
|
|
644
665
|
- 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
|
|
645
|
-
- 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`)
|
|
666
|
+
- 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
|
|
667
|
+
- Current slots worth knowing: `HALO_MIGRATIONS` v2 — the `disabled_items` rebuild above (rows kept). `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); column semantics in [cron.md](cron.md)
|
|
646
668
|
- 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
|
|
647
669
|
- Do not drop or rename existing columns
|
|
648
670
|
- New channel types: add rows with a new `channel_type` value + define config shape in the channel's `accounts.ts` adapter
|
|
@@ -660,3 +682,4 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
|
|
|
660
682
|
| v1+ | 2026-08-08 | Session file slimming (no version bump — additive + reader-compatible): assistant `toolCalls` no longer written (`contentBlocks` is the single copy, `toolCalls` stays read-only legacy); files written as compact JSON; UI-log archiving adds header fields `archiveCount` / `archivedUserCount` plus `{sessionId}.arch.{N}.json.gz` segment files; `agent_sessions` gains mirrored `title` / `exchange_count` / `context_tokens` / `total_output_tokens` columns so listing doesn't parse every file. |
|
|
661
683
|
| v1+ | 2026-09-18 | Relay + liveness (additive, no version bump): `agent_sessions.reply_to` column (idempotent ALTER); session file gains `lastActivityAt` beside `output`, read back by `get_session_output` for released sessions. |
|
|
662
684
|
| v1+ | 2026-09-20 | Schema migrations moved from ad-hoc boot-time ALTERs to `PRAGMA user_version` + ordered per-db migration lists (`db/migrate.ts`); existing dbs pick up at slot 0 and every slot is idempotent. Session-message + WS frame types moved to `@turmind/halo-core/protocol` (re-exported from their old server paths; no wire change). |
|
|
685
|
+
| v1+ | 2026-10-10 | Model registry (additive): per-model `endpoints:`, `maxContextWindow`, `compressAt`; recommended `contextWindow` ≤ 272000; registry context / compressAt / verbosity defaults are scaffold-only; installer validates model fields and asks consent for URLs new to a provider. `HALO_MIGRATIONS` v2: `disabled_items.scope` admits `'extension'`. `CRON_MIGRATIONS` v5: `cron_jobs.working_dir`, `cron_jobs.stateless`, `cron_runs.session_id`. |
|
|
@@ -138,7 +138,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
|
|
|
138
138
|
| `chat:stopped` | `chat:stop` / `chat:interrupt` handlers (this client only) | Stop/interrupt acknowledged — `{sessionId}` |
|
|
139
139
|
| `session:compacted` | compact handler | Compaction complete — `{message?, contextTokens, sessionId}` |
|
|
140
140
|
| `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`). |
|
|
141
|
-
| `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. See [cron.md](cron.md). |
|
|
141
|
+
| `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). |
|
|
142
142
|
| `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). |
|
|
143
143
|
| `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). |
|
|
144
144
|
| `extension:changed` | `extensions/watcher.ts` `rescanAndBroadcast` (broadcast to all clients) | `~/.halo/global/extensions/` changed (install / upgrade / uninstall, from the admin upload, the `extension` skill, or a manual `mv`/`rm`) — carries the **full** `ExtensionsSnapshot` (`{extensions, errors}`), not a diff, and only fires when the snapshot key differs from the last one. The admin replaces its extension list wholesale: the preview registry re-resolves open tabs (an upgraded extension remounts, or shows a banner if the tab is dirty; an uninstalled one falls back to the "no built-in preview" page) and the Settings → Extensions panel re-renders. Global, no workspace marker — extensions are server-wide. See [canvas-extensions.md](canvas-extensions.md). |
|
|
@@ -27,13 +27,13 @@ Runtime names (`runtime:` value → class): `bedrock-invoke` → `BedrockAgent`,
|
|
|
27
27
|
| `deepseek` | `deepseek` | `DeepSeekAgent` | `deepseek-agent.ts` | OpenAI-compatible, streaming via `fetchChatCompletionStream` (first chunk has `content: null` + `reasoning_content: ''`; the usage chunk still carries `choices`), thinking (reasoning_content). Vision per model: `deepseek-flash` (V4.1-Flash) sees `image_url` data URLs; `deepseek-v4-pro` accepts the block but ignores it (live-probed 2026-09-24 with a 64×64 blue PNG) — registry `image` flag gates it |
|
|
28
28
|
| `minimax` | `minimax` | `MiniMaxAgent` | `minimax-agent.ts` | Anthropic Messages API (x-api-key), streaming via `fetchAnthropicStream` — note `message_start.usage` is all zeros and the real input / cache counts arrive in `message_delta.usage` (the accumulator takes them from there). Two thinking schemes, branched on registry `thinking.mode`: **M3** (default) — vision, thinking off by default and enabled via `thinking:{type:'adaptive'}` (no levels, no budget_tokens), no `promptCaching` in the manifest on purpose (official docs scope `cache_control` to M2.x only; M3 has passive automatic caching); **M2.7** — no vision, thinking always-on (manual budget_tokens), prompt caching (5m, M2.7 doesn't report cache_creation_input_tokens) |
|
|
29
29
|
| `qwen` | `qwen` | `QwenAgent` | `qwen-agent.ts` | Aliyun Bailian Anthropic-compatible API, streaming via `fetchAnthropicStream` (SSE frames come as `data:{…}` with no space after the colon — the reader accepts both), thinking enable/disable both honored, prompt caching (5m only), vision on Plus, max no vision |
|
|
30
|
-
| `hunyuan` | `hunyuan` | `HunyuanAgent` | `hunyuan-agent.ts` | Tencent Hy3 preview via OpenAI-compatible (tokenhub.tencentmaas.com), streaming via `fetchChatCompletionStream` (`stream_options.include_usage` accepted; usage in its own `choices: []` chunk), reasoning_effort low/high (
|
|
31
|
-
| `doubao` | `doubao` | `DoubaoAgent` | `doubao-agent.ts` | ByteDance Doubao Seed-2.0 (pro/lite/mini/code) on Volcengine Ark, OpenAI-compatible, streaming via `fetchChatCompletionStream` (usage in its own `choices: []` chunk) + `thinking:{type:'enabled'/'disabled'}` (no `auto`), automatic prompt caching,
|
|
30
|
+
| `hunyuan` | `hunyuan` | `HunyuanAgent` | `hunyuan-agent.ts` | Tencent Hy3 / Hy4 preview / Hy3 preview via OpenAI-compatible (tokenhub.tencentmaas.com), streaming via `fetchChatCompletionStream` (`stream_options.include_usage` accepted; usage in its own `choices: []` chunk), reasoning_effort low/high, thinking off sends `reasoning_effort: 'none'` (an omitted field thinks by default — live-probed 2026-10-10), automatic prompt caching (cached_tokens), no vision |
|
|
31
|
+
| `doubao` | `doubao` | `DoubaoAgent` | `doubao-agent.ts` | ByteDance Doubao Seed 2.1 Pro + Seed-2.0 (pro/lite/mini/code) on Volcengine Ark, OpenAI-compatible, streaming via `fetchChatCompletionStream` (usage in its own `choices: []` chunk) + `thinking:{type:'enabled'/'disabled'}` (no `auto`), automatic prompt caching. Images go out as `image_url` content parts; only 2.1 Pro is `image: true` (the 2.0 models misread a red PNG), so the registry flag gates the rest |
|
|
32
32
|
| `zhipu` | `zhipu` | `ZhipuAgent` | `zhipu-agent.ts` | Zhipu GLM-5.x on open.bigmodel.cn, OpenAI-compatible, streaming via `fetchChatCompletionStream` (tool-call arguments arrive complete in one fragment; usage chunk carries `choices`). Output cap via `max_tokens` only (`max_completion_tokens` is silently ignored). Thinking branched on modelId: **glm-5.3\*** always thinks — always sends `thinking:{type:'enabled',clear_thinking:false}`, `reasoning_effort` only when low/high/max (`disabled` / other efforts 400); other ids (glm-5.2) honour `thinking:{type:'disabled'}`. Vision per model: `glm-5.3-flash` / `glm-5.3-flashx` see `image_url` data URLs; `glm-5.3` / `glm-5.2` are text-only and 400 on any image part (code 1210, classified `multimodal_4xx` so leftover history images degrade). Automatic caching, `prompt_tokens_details.cached_tokens` (included in `prompt_tokens`, subtracted). |
|
|
33
|
-
| `openai` | `openai-chat` | `OpenAIAgent` | `openai-agent.ts` | **Generic OpenAI-compatible**. Use for native OpenAI or any third-party OpenAI gateway (incl. Ollama / llama.cpp).
|
|
34
|
-
| `anthropic` | `anthropic-messages` | `AnthropicAgent` | `anthropic-agent.ts` | **Generic Anthropic Messages**. Use for api.anthropic.com or any Anthropic-compatible gateway.
|
|
33
|
+
| `openai` | `openai-chat` | `OpenAIAgent` | `openai-agent.ts` | **Generic OpenAI-compatible**. Use for native OpenAI or any third-party OpenAI gateway (incl. Ollama / llama.cpp). Ships a short Bedrock Mantle list callable with a Bedrock API key (`openai.gpt-5.5` default, `openai.gpt-5.4`; `defaultEndpoint` Mantle us-east-1 `/openai/v1`, presets us-east-1 / us-west-2 / api.openai.com — GPT-6.x / 5.6 reject `max_tokens`, so they stay on the Responses-API providers) plus the legacy `gpt-4o` placeholder (official API only); any other endpoint + model id can be typed, and capabilities for unlisted ids are openly editable in the form. Streaming via `fetchChatCompletionStream` — always sends `stream: true` + `stream_options: { include_usage: true }` (OpenAI's own field; Ollama ≥ 0.3 / vLLM / LM Studio accept it too). A gateway that rejects unknown fields would 400 — accepted trade-off, no non-streaming fallback. Forgiving about cache field names (`cachedPromptTokens`: first value > 0 of `prompt_tokens_details.cached_tokens`, top-level `cached_tokens`, `prompt_cache_hit_tokens`, `cache_read_tokens`) and reasoning field names (`reasoning_content` for OpenAI o-series/DeepSeek, `reasoning` for Ollama/llama.cpp). |
|
|
34
|
+
| `anthropic` | `anthropic-messages` | `AnthropicAgent` | `anthropic-agent.ts` | **Generic Anthropic Messages**. Use for api.anthropic.com or any Anthropic-compatible gateway. Ships a short Bedrock Mantle list callable with a Bedrock API key as `x-api-key` (`anthropic.claude-opus-5-5` default, `anthropic.claude-haiku-4-5`; `defaultEndpoint` Mantle us-east-1 `/anthropic`, presets us-east-1 / us-west-2 / api.anthropic.com) plus the legacy `claude-sonnet-4-6` placeholder (official API only); any other endpoint + model id can be typed. Streaming via `fetchAnthropicStream` (the gateway must support `stream: true` SSE — the reference Anthropic behaviour). Standard `cache_control: ephemeral` with optional `ttl: '1h'`. |
|
|
35
35
|
| `mimo-token-plan-china` | `anthropic-messages` | `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, streams like `anthropic` (gateway built for Claude Code, which always streams; not live-probed); no dedicated class, just `runtime: anthropic-messages` in its yaml (`defaultEndpoint` `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 |
|
|
36
|
-
| `aws-bedrock-mantle` | `bedrock-mantle` | `MantleAgent` | `mantle-agent.ts` | OpenAI GPT-6.1 Sol, 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.1 Sol + GPT-6 Sol / Luna + every 5.6 model (404s Astra; GPT-6.1 Sol probed 2026-09-30, us-east-1 only); us-east-2 serves only the 5.6 trio; us-west-2 serves Astra + 5.6 Terra / Luna. Template ships all three as `endpointPresets
|
|
36
|
+
| `aws-bedrock-mantle` | `bedrock-mantle` | `MantleAgent` | `mantle-agent.ts` | OpenAI GPT-6.1 Sol, 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.1 Sol + GPT-6 Sol / Luna + every 5.6 model (404s Astra; GPT-6.1 Sol probed 2026-09-30, us-east-1 only); us-east-2 serves only the 5.6 trio; us-west-2 serves Astra + 5.6 Terra / Luna. Template ships all three as `endpointPresets` (us-east-1 / us-west-2 / us-east-2, default us-east-1, `openai.gpt-6-sol` default model) and pins each model's regions with per-model `endpoints:` (Astra us-west-2; GPT-6.1 Sol / GPT-6 Sol / Luna us-east-1; GPT-5.6 Sol us-east-1 + us-east-2; Terra / Luna none = all presets), so the form only offers — and on a model switch moves to — a region that serves the model; `extractRegionFromHost` falls back to us-east-1. GPT-6 Astra / Sol / Luna: 1,050,000 context / 128,000 max output; GPT-6.1 Sol 1M / 131,072 (card; Mantle accepts any `max_output_tokens` ≥ 16, so the cap isn't enforced server-side); same effort (none/low/medium/high/xhigh/max) + verbosity surface. Input beyond 272K tokens bills at 2x, so every entry ships `contextWindow: 272000` (what a new agent gets) with the 1M-class figure as `maxContextWindow`; raise `context.maxTokens` past 272K only on purpose. 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. Streams (`stream: true`, Responses API SSE folded by `readResponsesStream` in the same file): `response.output_text.delta` frames go out live, the terminal `response.completed` / `response.incomplete` frame carries the full final response object; this host sends **no** `data: [DONE]` (live-probed 2026-09-29). |
|
|
37
37
|
| `aws-bedrock-openai` | `bedrock-mantle` | `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 its yaml just says `runtime: bedrock-mantle` + `extractRegionFromHost` accepts 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 nine `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 seven **GPT-6.1 / 6 / 5.6** entries as the Mantle template (`global.openai.gpt-6.1-sol`, `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 and GPT-6.1 Sol 400 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. Streams like Mantle (same event vocabulary) but this host **does** append a trailing `data: [DONE]`, which `readSseJson` skips; grok-4.6 emits a `reasoning` output item (no summary text deltas) before the `message` item. |
|
|
38
38
|
|
|
39
39
|
---
|
|
@@ -164,12 +164,17 @@ revision: 2026100501 # required for bundled / hub yamls — in
|
|
|
164
164
|
displayName: My Provider
|
|
165
165
|
description: Short description
|
|
166
166
|
defaultEndpoint: https://api.example.com/v1
|
|
167
|
+
endpointPresets: # optional — the form's endpoint dropdown
|
|
168
|
+
- https://api.example.com/v1
|
|
167
169
|
|
|
168
170
|
models:
|
|
169
171
|
- id: my-model-v1
|
|
170
172
|
displayName: My Model V1
|
|
173
|
+
# endpoints: [https://api.example.com/v1] # optional — only when this model is served on a subset of the presets
|
|
171
174
|
maxOutputTokens: 16384
|
|
172
|
-
contextWindow: 200000 #
|
|
175
|
+
contextWindow: 200000 # recommended window for a new agent: official figure, ≤ 272000
|
|
176
|
+
maxContextWindow: 200000 # the model's true limit (absent → contextWindow)
|
|
177
|
+
compressAt: 0.9 # compaction ratio for a new agent, (0, 1]
|
|
173
178
|
capabilities:
|
|
174
179
|
image: true # supports image input
|
|
175
180
|
video: false # no video understanding
|
|
@@ -185,12 +190,13 @@ models:
|
|
|
185
190
|
|
|
186
191
|
**Capability flags drive the UI.** The Form view reads this file directly to decide which controls to render:
|
|
187
192
|
- `defaultEndpoint` → auto-fills endpoint when switching provider in agent/settings UI
|
|
193
|
+
- `endpointPresets` → the endpoint dropdown; a model's own `endpoints:` replaces it for that model, and the form moves the endpoint onto that list when the user switches to the model (`resolveModelEndpoints`, `models/model-endpoints.ts` + the admin copy)
|
|
188
194
|
- `capabilities.image` / `capabilities.video` / `capabilities.audio` → modality badges (green = supported, grey = unsupported); also controls runtime behavior in two ways: (a) the `view_image` workspace tool is dropped from the agent's tool list at session-create time when `image: false`, so the model never sees a tool the provider would 400 on; (b) user-supplied images on inbound messages are filtered with a text notice instead of being sent to the API
|
|
189
195
|
- `capabilities.promptCaching.ttlPresets` → renders the Prompt Caching dropdown with those options
|
|
190
196
|
- `capabilities.thinking.effortPresets` → renders the Thinking effort dropdown
|
|
191
197
|
- Omit either block → the UI hides that control for this model
|
|
192
198
|
|
|
193
|
-
|
|
199
|
+
**Context fields** — `contextWindow` is the *recommended* window, written into a new agent's `context.maxTokens`: the official figure, capped at 272000 (1M-class models bill input past 272K at long-context rates); `maxContextWindow` is the model's true limit; `compressAt` the new agent's compaction ratio. Read through `resolveModelContext()` / `resolveEffectiveContext()` in [config.ts](../../../packages/server/src/config.ts). At runtime a session's budget (compaction threshold, token ring) is agent.yaml `context.maxTokens` / `compressAt`, else the global defaults (`config.model.maxContextTokens` 272K capped at the model's max, `general.compact.compress_at` 0.9), then clamped to the model's max — the same chain for live sessions (`buildModelRuntime`, session-agent-builder.ts) and cold ones (`getContextConfig()`, session-manager.ts). The registry's recommended `contextWindow` / `compressAt` and `capabilities.verbosity.default` are **scaffold-only**: written into agent.yaml when an agent is created or the form switches model, never read at runtime for an existing agent. A new agent's compressAt is the explicitly set global `compress_at`, else the model's `compressAt`, else 0.9. Declare all three on every model entry; without `maxContextWindow` / `contextWindow` a small-window model (e.g. 128K) runs at 272K and overflows before compaction kicks in. Details: [design/storage.md#model-registry-format](../design/storage.md#model-registry-format).
|
|
194
200
|
|
|
195
201
|
Shape source: [packages/server/templates/models/aws-bedrock-claude-invoke.yaml](../../../packages/server/templates/models/aws-bedrock-claude-invoke.yaml) and [design/storage.md#model-registry-format](../design/storage.md#model-registry-format).
|
|
196
202
|
|
|
@@ -198,7 +204,7 @@ Shape source: [packages/server/templates/models/aws-bedrock-claude-invoke.yaml](
|
|
|
198
204
|
|
|
199
205
|
### Distributing a provider change through the hub
|
|
200
206
|
|
|
201
|
-
A new model, a renamed model id or a changed endpoint doesn't need a halo release: edit the yaml in [halo-hub](https://github.com/turmind/halo-hub) `models/`, bump its `revision`, `node scripts/pack.mjs models`, and publish the zip as release `models-v<YYYY.MM.DD>` (see the hub README). Users run `/extension models` (or say "更新模型列表"); `halo models install` writes the files into `models.d/`, and the server's `models.d` watcher drops the registry cache and pushes `models:changed`, so open admins re-fetch — no restart, no reload. Install-time refusals: a `runtime:` this build doesn't know ("needs a newer halo" — a **new runtime class always needs a halo release**), a missing `revision`, a `secrets[].default` other than empty / `<<ENV_NAME>>`. A new provider
|
|
207
|
+
A new model, a renamed model id or a changed endpoint doesn't need a halo release: edit the yaml in [halo-hub](https://github.com/turmind/halo-hub) `models/`, bump its `revision`, `node scripts/pack.mjs models`, and publish the zip as release `models-v<YYYY.MM.DD>` (see the hub README). Users run `/extension models` (or say "更新模型列表"); `halo models install` writes the files into `models.d/`, and the server's `models.d` watcher drops the registry cache and pushes `models:changed`, so open admins re-fetch — no restart, no reload. Install-time refusals: a `runtime:` this build doesn't know ("needs a newer halo" — a **new runtime class always needs a halo release**), a missing `revision`, a `secrets[].default` other than empty / `<<ENV_NAME>>`. Also refused: model `contextWindow` / `maxContextWindow` that aren't positive integers, `contextWindow` > `maxContextWindow`, `compressAt` outside (0, 1], `endpoints` that isn't a list of `https://` URLs. A new provider, a changed `defaultEndpoint` / `endpointPresets`, or a model `endpoints:` URL the provider didn't use anywhere before is listed and needs the user's explicit OK (`--yes`). Mirror the change in `templates/models/` with the same revision so the next release bundles it.
|
|
202
208
|
|
|
203
209
|
### 3. Wire the dispatcher
|
|
204
210
|
|
|
@@ -289,7 +295,7 @@ If something's wrong, server logs under `~/.halo/global/logs/server.log` are the
|
|
|
289
295
|
|
|
290
296
|
(A curated provider of your own for a stock-protocol vendor is just a manifest with `runtime: openai-chat` or `runtime: anthropic-messages` — `mimo-token-plan-china` is that case.)
|
|
291
297
|
|
|
292
|
-
Two of the providers above are deliberately generic — they take whatever `endpoint` + `model id` the user fills in the form
|
|
298
|
+
Two of the providers above are deliberately generic — they take whatever `endpoint` + `model id` the user fills in the form. Their model list is short on purpose: a few Bedrock Mantle models that work out of the box with a Bedrock API key (the default endpoint is Mantle us-east-1) plus one legacy placeholder, and the models census skips them — new models are tracked through the `aws-bedrock-*` providers. Use them when:
|
|
293
299
|
|
|
294
300
|
- The new vendor speaks **stock OpenAI chat completions** or **stock Anthropic Messages** with no quirks worth coding around. Examples: self-hosted vLLM, third-party OpenAI gateway.
|
|
295
301
|
- You don't want to maintain a model registry for that vendor — users are expected to know what model id and endpoint to use.
|
package/bundled-docs/dev/api.md
CHANGED
|
@@ -95,6 +95,12 @@ Installed extensions are directories under `~/.halo/global/extensions/<id>/` wit
|
|
|
95
95
|
| DELETE | `/api/extensions/:id` | cookie | Remove the directory. 400 for an invalid id shape, 404 if not installed. Returns `{ok, id}` |
|
|
96
96
|
| GET | `/api/extensions/:id/:version/:token/<asset>` | **path token, no cookie** | Static file from the extension directory. 401 unless `verifyScopedToken(token,'ext')`; 404 if `version` ≠ installed version or the asset escapes the directory. Response headers: `Access-Control-Allow-Origin: *`, `Referrer-Policy: no-referrer`, `Cache-Control: public, max-age=31536000, immutable` (the version segment is the cache key) |
|
|
97
97
|
| WS | `/api/transcribe/stream?ext=<id>&lang=<auto\|xx-XX>` | cookie | Streaming transcription proxy to Amazon Transcribe (`routes/transcribe-ws.ts`; not mounted in AgentCore mode). Handshake 401 without the login cookie, 403 unless `ext` is installed and declares capability `transcribe`. Client → binary PCM s16le mono 16 kHz, text `{"type":"end"}` (flush, server closes 1000). Server → `{"type":"ready"}` (on connect; stream audio right after it), `{"type":"partial"\|"final",start,end,text,lang}` (seconds from the stream's first audio byte), `{"type":"error",code,message}` then close; `code` ∈ `credentials` \| `denied` \| `limit` \| `bad-request` \| `io`. Region / languages / credentials from the extension's `ext-<id>` settings (default chain when no keys) — see [design](../design/canvas-extensions.md#streaming-transcription-proxy) |
|
|
98
|
+
| GET | `/api/jupyter/status` | cookie | Notebook kernels (`routes/jupyter.ts`; not mounted in AgentCore mode). `{ enabled, available, version?, error?, hint? }` — never starts the server; disabled → `enabled:false, available:false`; Jupyter missing → `available:false` + `error` + `hint: 'pip install jupyter_server ipykernel'` |
|
|
99
|
+
| GET | `/api/jupyter/kernelspecs` | cookie | `{ default, specs: [{ name, displayName, language }] }`. Starts the local jupyter server on demand; 503 `{error, hint?}` when disabled / unavailable / it fails to start |
|
|
100
|
+
| POST | `/api/jupyter/kernels` | cookie | Body `{ projectId, path, name }` (`path` = workspace-relative notebook path, as in `init.file.path`). 400 for a missing field, unknown project, path outside the workspace or a missing directory. Starts kernel `name` with cwd = the notebook's directory → `{ id, name }` |
|
|
101
|
+
| POST | `/api/jupyter/kernels/:id/interrupt` · `…/restart` | cookie | `{ ok: true }`; 404 unless `:id` is a kernel Halo started (and jupyter still has) |
|
|
102
|
+
| DELETE | `/api/jupyter/kernels/:id` | cookie | Shut the kernel down → `{ ok: true }`; 404 for an unknown id |
|
|
103
|
+
| WS | `/api/jupyter/channels?kernel=<id>` | cookie | Kernel channels: frames piped as-is both ways to the upstream `/api/kernels/<id>/channels` — legacy JSON text protocol (`channel` field), no subprotocol. Handshake 401 without the cookie, 404 for an unknown kernel. The last socket closing starts a 60 s grace, then the kernel is shut down — see [design](../design/canvas-extensions.md#notebook-kernels-jupyter-proxy) |
|
|
98
104
|
|
|
99
105
|
Extension capabilities the host understands (`ExtensionCapability`): `save`, `media`, `transcribe`, `fs-read`; an unknown value makes the manifest an error. `fs-read` adds no extension-specific route: the host serves the extension's scoped `fs` frames and `pick` file picker from the [Filesystem browse](#filesystem-browse-not-project-scoped--for-the-workspace-picker) routes above (`scope: 'system'`) and the ordinary `/api/files/*` routes (`scope: 'workspace'`) — see [design/canvas-extensions.md](../design/canvas-extensions.md#scoped-file-access-and-the-picker-fs-read).
|
|
100
106
|
|
|
@@ -117,7 +123,7 @@ Unified session log API — list + read session files across all agents.
|
|
|
117
123
|
| PATCH | `/api/sessions/logs/:id?projectId=` | Rename a session (admin-only; body `{title}`) — updates the log file's title and the mirrored `agent_sessions.title`. Accepts any session id (root or sub-agent); the sidebar exposes the rename affordance on every row |
|
|
118
124
|
| GET | `/api/sessions/goal?projectId=` | Legacy goal binding of the workspace (`{goal: {goalSessionId, workerSessionId, status, round, maxRounds} \| null}`); refresh seed for the goal banner, which only shows when `general.goal_mode_enabled` is on |
|
|
119
125
|
|
|
120
|
-
The list endpoint returns flat metadata (id / agentId / agentName / title / timestamps / exchangeCount / parentSessionId / stoppedAt / archivedAt / contextTokens / totalOutputTokens / status / goalSessionId — the
|
|
126
|
+
The list endpoint returns flat metadata (id / agentId / agentName / title / timestamps / exchangeCount / parentSessionId / stoppedAt / archivedAt / contextTokens / totalOutputTokens / status / workingDir / goalSessionId — `workingDir` is the persisted workspace-relative folder, `null` = the root; `goalSessionId` is a retained legacy field, non-null only on a goal-bound worker row; existing bindings are unchanged), served from the mirrored `agent_sessions` columns rather than by parsing each session file (rows predating the columns are backfilled from the file on first read). The frontend builds the tree from `parentSessionId`.
|
|
121
127
|
|
|
122
128
|
The get endpoint returns the full session file. If only `rawMessages` is present (no event-log `messages`), `convertRawMessages()` transforms it into display format on the fly.
|
|
123
129
|
|
|
@@ -208,9 +214,9 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
|
|
|
208
214
|
|
|
209
215
|
| Method | Path | Purpose |
|
|
210
216
|
|---|---|---|
|
|
211
|
-
| 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),
|
|
212
|
-
| 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 session (id = the job id). A picked session that already exists keeps its own
|
|
213
|
-
| 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 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). Flips schedule/unschedule on `enabled`. Returns `{ok}` |
|
|
217
|
+
| 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) |
|
|
218
|
+
| 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. `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}` |
|
|
219
|
+
| 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 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}` |
|
|
214
220
|
| DELETE | `/api/cron/jobs/:id` | Unschedule, delete every `cron_runs` row for the job, then delete the job. Returns `{ok}` |
|
|
215
221
|
| POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget). Returns `{ok}` |
|
|
216
222
|
| POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
|
|
@@ -221,7 +227,7 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
|
|
|
221
227
|
|
|
222
228
|
| Method | Path | Purpose |
|
|
223
229
|
|---|---|---|
|
|
224
|
-
| 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 |
|
|
230
|
+
| 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) |
|
|
225
231
|
| 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) |
|
|
226
232
|
|
|
227
233
|
### Reference data (for the create form)
|
|
@@ -403,7 +409,7 @@ File: `packages/server/src/routes/agent-configs.ts`
|
|
|
403
409
|
| GET | `/api/agent-configs/models` | Available model providers and models |
|
|
404
410
|
| POST | `/api/agent-configs` | Create a new agent |
|
|
405
411
|
| GET | `/api/agent-configs/:id/yaml?scope=&projectId=` | Read agent.yaml. `scope=workspace` without `projectId` → 400; `scope` other than `global` / `workspace` (omitted = `global`) → 400 `Invalid scope` |
|
|
406
|
-
| PUT | `/api/agent-configs/:id/yaml?scope=&projectId=` | Write agent.yaml. Same 400s as GET |
|
|
412
|
+
| PUT | `/api/agent-configs/:id/yaml?scope=&projectId=` | Write agent.yaml. Same 400s as GET, plus 400 `context.maxTokens <n> exceeds the max context of <model> (<max>)` when the window is above the model's `maxContextWindow` — unless the model and the value are both unchanged from the file on disk (older scaffolds wrote 272000 for every model; those agents stay editable) |
|
|
407
413
|
| DELETE | `/api/agent-configs/:id?scope=&projectId=` | Delete agent (global has "last one" protection). Same 400s as GET — never falls back to the same-named global agent or skips the last-one guard |
|
|
408
414
|
| PATCH | `/api/agent-configs/:id/toggle?scope=&projectId=` | Toggle disabled in workspace DB. `projectId` required. Returns `{ ok, disabled }`. |
|
|
409
415
|
| GET | `/api/agent-configs/:id/md/:fileType` | Read an MD file (AGENT.md / INSTRUCTIONS.md / INDEX.md) |
|
|
@@ -423,10 +429,10 @@ File: `packages/server/src/routes/skills.ts`
|
|
|
423
429
|
|
|
424
430
|
| Method | Path | Purpose |
|
|
425
431
|
|---|---|---|
|
|
426
|
-
| GET | `/api/skills?projectId=` | List skills (global + workspace unmerged —
|
|
427
|
-
| POST | `/api/skills` | Create a skill directory + SKILL.md |
|
|
428
|
-
| DELETE | `/api/skills/:id?scope=&projectId=` | Delete skill directory + settings entry |
|
|
429
|
-
| PATCH | `/api/skills/:id/toggle?scope=&projectId=` | Toggle disabled in workspace DB. `projectId` required. Returns `{ ok, disabled }`. |
|
|
432
|
+
| GET | `/api/skills?projectId=` | List skills (global + workspace + extension unmerged — distinct entries). An item shipped by an installed extension has `scope: 'extension'` plus `extId` (the owning extension id). An entry shadowed at runtime by a same-id higher-precedence skill (workspace > global > extension) is marked `overridden: true`. Runtime override happens in `agent-loader.ts`, not in this route. |
|
|
433
|
+
| POST | `/api/skills` | Create a skill directory + SKILL.md. `scope: 'extension'` → 403 (extension skills are read-only) |
|
|
434
|
+
| DELETE | `/api/skills/:id?scope=&projectId=` | Delete skill directory + settings entry. `scope=extension` → 403 (read-only) |
|
|
435
|
+
| PATCH | `/api/skills/:id/toggle?scope=&projectId=` | Toggle disabled in workspace DB. `projectId` required; `scope` is `global` / `workspace` / `extension` (an extension skill's disable key is `extension:<id>` — a workspace setting, nothing written into the extension's folder). Returns `{ ok, disabled }`. |
|
|
430
436
|
|
|
431
437
|
## Commands
|
|
432
438
|
|
|
@@ -460,6 +466,13 @@ PATCH / DELETE walk the dotted key layer by layer, so a segment of `__proto__` /
|
|
|
460
466
|
|
|
461
467
|
All four are `globalOnly`, take effect on restart. See [design/observability.md](../design/observability.md).
|
|
462
468
|
|
|
469
|
+
### Notebook kernel settings
|
|
470
|
+
|
|
471
|
+
- `general.jupyter.enabled` — allow notebook execution through the `/api/jupyter/*` proxy, default `true`
|
|
472
|
+
- `general.jupyter.path` — full path of the `jupyter` executable; empty (default) = `jupyter` on PATH
|
|
473
|
+
|
|
474
|
+
Both `globalOnly`, read at each server spawn / status probe — no restart. See [design/canvas-extensions.md](../design/canvas-extensions.md#notebook-kernels-jupyter-proxy).
|
|
475
|
+
|
|
463
476
|
## Metrics
|
|
464
477
|
|
|
465
478
|
File: `packages/server/src/routes/metrics.ts`.
|