@turmind/halo 1.5.7 → 1.5.9
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/220.7566bc89f3d98993.js +1 -0
- package/admin-out/_next/static/chunks/597-399e2e1de26aa747.js +1 -0
- package/admin-out/_next/static/chunks/989-c404f6f43c400911.js +1 -0
- package/admin-out/_next/static/chunks/app/page-59bfce35bbb30b05.js +1 -0
- package/admin-out/_next/static/chunks/{webpack-edf248e07333566d.js → webpack-efd27999305b080a.js} +1 -1
- package/admin-out/_next/static/css/27b44c85485eabb9.css +3 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/architecture.md +3 -2
- package/bundled-docs/design/canvas-extensions.md +51 -11
- package/bundled-docs/design/cron.md +6 -6
- package/bundled-docs/design/evolution.md +47 -15
- package/bundled-docs/design/express-self.md +96 -20
- package/bundled-docs/design/storage.md +10 -2
- package/bundled-docs/design/ws.md +1 -0
- package/bundled-docs/dev/add-model-provider.md +16 -7
- package/bundled-docs/dev/api.md +7 -6
- package/bundled-docs/dev/desktop-packaging.md +26 -0
- package/bundled-docs/guide/agents.md +3 -3
- package/bundled-docs/guide/chat.md +3 -0
- package/bundled-docs/guide/cli.md +10 -1
- package/bundled-docs/guide/workspace.md +2 -2
- package/bundled-docs/requirements/agent.md +1 -1
- package/bundled-docs/requirements/chat.md +17 -9
- package/bundled-docs/requirements/editor.md +3 -1
- package/bundled-docs/requirements/explorer.md +4 -2
- package/bundled-docs/requirements/settings.md +11 -3
- package/bundled-docs/requirements/workspace.md +2 -2
- package/dist/index.js +2514 -1669
- package/package.json +2 -1
- package/templates/agents/__evo_agent__/AGENT.md +7 -0
- package/templates/agents/__score__/AGENT.md +183 -71
- package/templates/canvas/self.html +617 -71
- package/templates/models/anthropic.yaml +1 -0
- package/templates/models/aws-bedrock-claude-invoke.yaml +1 -0
- package/templates/models/aws-bedrock-mantle.yaml +1 -0
- package/templates/models/aws-bedrock-openai.yaml +1 -0
- package/templates/models/deepseek.yaml +1 -0
- package/templates/models/doubao.yaml +1 -0
- package/templates/models/hunyuan.yaml +1 -0
- package/templates/models/kimi.yaml +1 -0
- package/templates/models/mimo-token-plan-china.yaml +1 -0
- package/templates/models/minimax.yaml +1 -0
- package/templates/models/openai.yaml +1 -0
- package/templates/models/qwen.yaml +1 -0
- package/templates/models/zhipu.yaml +1 -0
- package/templates/skills/cron/SKILL.md +59 -4
- package/templates/skills/cron/manage-cron.py +158 -24
- package/templates/skills/extension/SKILL.md +30 -2
- package/templates/skills/extension/config.yaml +2 -2
- package/templates/skills/extension/templates/ext.sh +69 -16
- package/templates/skills/self/SKILL.md +79 -17
- package/admin-out/_next/static/chunks/220.e250bbbf65d24094.js +0 -1
- package/admin-out/_next/static/chunks/422-cc74b1fdd3af5415.js +0 -1
- package/admin-out/_next/static/chunks/989-e0faeff1c4a0b0b9.js +0 -1
- package/admin-out/_next/static/chunks/app/page-44f707ea8dad57da.js +0 -1
- package/admin-out/_next/static/css/b8c87b919758c52b.css +0 -3
- /package/admin-out/_next/static/{rTD3zkMvzwtqUwzhz6QJH → RD5w3FmDZ6HtyQGGaUb8t}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{rTD3zkMvzwtqUwzhz6QJH → RD5w3FmDZ6HtyQGGaUb8t}/_ssgManifest.js +0 -0
|
@@ -27,7 +27,7 @@ Two halves, cleanly split:
|
|
|
27
27
|
|
|
28
28
|
Schedules are **durable** — the source of truth is the `cron_jobs` table; in-memory croner state is rebuilt on every server boot (`startCronDaemon` → `reloadAll`). A restart never loses a schedule.
|
|
29
29
|
|
|
30
|
-
- **Recurring**: standard cron expression (`job.schedule`), optional `timezone
|
|
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
32
|
|
|
33
33
|
### Trigger-mode exclusivity (the db contract)
|
|
@@ -44,13 +44,13 @@ Exactly **one** of `schedule` / `runAt` is set per job. `runner.ts` `scheduleJob
|
|
|
44
44
|
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
45
|
|
|
46
46
|
- 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|schedule|runAt|timezone`); schedule new rows, unschedule deleted ones, re-instantiate croner only for rows whose fingerprint changed. Broadcasts a coalesced `cron:job_changed` per affected job.
|
|
47
|
+
- On change: diff db rows against the in-memory `_fingerprint` map (`enabled|schedule|runAt|timezone|activeFrom|activeUntil`); 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
48
|
|
|
49
49
|
## Execution (`runJob`)
|
|
50
50
|
|
|
51
51
|
Each fire:
|
|
52
52
|
|
|
53
|
-
1. **Concurrency guard**: resolve the job's session first (`effectiveSessionId` — `job.sessionId
|
|
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
54
|
- Same `jobId` already running in this process (in-memory `_inflight` set) → `previous run still in progress`.
|
|
55
55
|
- 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
56
|
- 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.
|
|
@@ -58,7 +58,7 @@ Each fire:
|
|
|
58
58
|
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
59
|
2. Insert a `cron_runs` row with `status='running'` up front (UI sees it mid-flight); broadcast `cron:run_changed`.
|
|
60
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
|
|
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* — with the session's own agent, `-a` is ignored for an existing one — so the conversation accumulates and is reviewable in the admin Sessions tab. 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=` / `access=`. 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
62
|
|
|
63
63
|
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
64
|
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.
|
|
@@ -75,7 +75,7 @@ The step-1 guard is per-process, so it has one blind spot: the server dies (SIGK
|
|
|
75
75
|
|
|
76
76
|
`startCronDaemon` calls `sweepOrphanRuns` **before** `reloadAll`. For each stale row:
|
|
77
77
|
|
|
78
|
-
1. Look up the job row to get its effective session (a job deleted since falls back to
|
|
78
|
+
1. Look up the job row to get its effective session (a job deleted since falls back 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).
|
|
79
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's session 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
80
|
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
81
|
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).
|
|
@@ -121,6 +121,6 @@ interface CronChannelDispatcher {
|
|
|
121
121
|
|
|
122
122
|
## Scope
|
|
123
123
|
|
|
124
|
-
Supported: recurring (cron expr) + one-shot (`runAt`) schedules; per-timezone; per-job configurable timeout (`timeoutSec`, 60–21600s, default 3600); multi-target fan-out; stable accumulating session per job — its own
|
|
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.
|
|
125
125
|
|
|
126
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).
|
|
@@ -93,13 +93,15 @@ The wrapper is the orchestrator; individual agents do focused work only. Every p
|
|
|
93
93
|
|
|
94
94
|
### Phase A — Draft
|
|
95
95
|
|
|
96
|
-
`spawn('halo', ['cli', '-a', '__evo_agent__', '-n', '-w', '<runDir>/sandbox'])` — the brief goes in on stdin (multi-KB; argv would hit Windows limits), capped at `
|
|
96
|
+
`spawn('halo', ['cli', '-a', '__evo_agent__', '-n', '-w', '<runDir>/sandbox'])` — the brief goes in on stdin (multi-KB; argv would hit Windows limits), capped at `DRAFT_TIMEOUT_SEC` (60 min; the Phase B fix pass gets the same). Dry-runs (`DRY_RUN_TIMEOUT_SEC`) and the other LLM phases — score, apply-merge, apply-score (`PHASE_TIMEOUT_SEC`) — are capped at 45 min. All are wall-clock caps on the sub-cli, not idle timers.
|
|
97
97
|
|
|
98
98
|
Agent writes:
|
|
99
99
|
- `patch.md` — Markdown with YAML frontmatter + body. Frontmatter includes `testScenario: { agentId, testMessage, originalMessage }` and `target: .halo/<path>`.
|
|
100
100
|
- One new file at `<runDir>/sandbox/.halo/<target>` with full patched contents.
|
|
101
101
|
- OR `.skip.md` (one-sentence reason) if no patch is worth proposing.
|
|
102
102
|
|
|
103
|
+
The wrapper's verdict on the result is `judgeDraft()`: `.skip.md` → skipped (wins even over a non-zero exit); drafter exited non-zero (124 = timeout) → failed — an interrupted draft is not trusted; otherwise `patch.md` must exist and its `target` must resolve under `sandbox/.halo/`, exist there, and differ from the main workspace's copy (when there is one) — any miss → failed. A patch without `target` (old format) is accepted as drafted. `sandbox/.halo` existing proves nothing on its own: `buildEvoSandbox` creates it before the drafter starts.
|
|
104
|
+
|
|
103
105
|
The `target` can be any file in the prompt surface — `INSTRUCTIONS.md`, the agent's `AGENT.md` / `agent.yaml`, a `skills/<id>/SKILL.md`, `prompts/<scope>/`, `INDEX.md` — routed by which file *owns* the failure (skill misuse → that SKILL.md, capability/model gap → agent.yaml, persona/scope → AGENT.md, cross-cutting rule → INSTRUCTIONS.md). AGENT.md carries the routing table; this de-biases the drafter from defaulting to INSTRUCTIONS.md just because its full text sits in the brief.
|
|
104
106
|
|
|
105
107
|
Sandbox is whitelist-cp from main workspace (only: `INSTRUCTIONS.md`, `INDEX.md`, `USER.md`, `agents/`, `prompts/`, `skills/`, `docs/`). Agent reads via `file_read`, writes only to `.halo/` subset.
|
|
@@ -110,7 +112,7 @@ The copy is `copyDereferenced` in `evo-wrapper.ts`, a hand-rolled recursive walk
|
|
|
110
112
|
|
|
111
113
|
```
|
|
112
114
|
halo cli -a <patch.testScenario.agentId> -n -w <runDir>/sandbox --access workspace
|
|
113
|
-
# testMessage on stdin; Node-side
|
|
115
|
+
# testMessage on stdin; Node-side 2700s timer → exit 124
|
|
114
116
|
```
|
|
115
117
|
|
|
116
118
|
`--access workspace` masks `~/.aws`, `~/.ssh`, etc. (safety: test probe's behavior, not side effects).
|
|
@@ -131,22 +133,52 @@ The score brief packs patch.md + dry-run-output.txt + meta.json + evo-context.js
|
|
|
131
133
|
|
|
132
134
|
```json
|
|
133
135
|
{
|
|
134
|
-
"lint":
|
|
135
|
-
"behavior":
|
|
136
|
-
"scope":
|
|
136
|
+
"lint": 100,
|
|
137
|
+
"behavior": 70,
|
|
138
|
+
"scope": 100,
|
|
137
139
|
"confidence": "high",
|
|
138
|
-
"avg":
|
|
139
|
-
"notes": "…"
|
|
140
|
+
"avg": 90,
|
|
141
|
+
"notes": "…",
|
|
142
|
+
"checks": {
|
|
143
|
+
"patchInSandbox": "pass",
|
|
144
|
+
"usedInDryRun": "yes",
|
|
145
|
+
"probe": "valid",
|
|
146
|
+
"probeLanguage": "same"
|
|
147
|
+
}
|
|
140
148
|
}
|
|
141
149
|
```
|
|
142
150
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
- **
|
|
147
|
-
|
|
151
|
+
patch.md is treated as the drafter's claim, not evidence — every score rests on what the scorer sees on disk. Before rating it runs three checks, recorded in `checks`:
|
|
152
|
+
|
|
153
|
+
1. **Patch in sandbox** (`patchInSandbox`) — the frontmatter `target` exists in the sandbox and differs from the pre-patch copy (workspace file, or the global one when the workspace had none) in the way the body describes.
|
|
154
|
+
2. **Dry-run used it** (`usedInDryRun`) — the dry-run session under `sandbox/.halo/sessions/<agentId>/` activated the target skill or read the sandbox copy; files loaded every turn count as used. A skill the `--access workspace` dry-run can't see (`requiresAccess: full`, `disable-model-invocation: true`, not in the test agent's `skills:`) is not used.
|
|
155
|
+
3. **Fair probe** (`probe` + `probeLanguage`) — `originalMessage` appears verbatim in tool-flow.md, and `testMessage` is labelled `valid` / `leading` (names the prescribed fix) / `easier` / `off_target`, plus whether it's in another language.
|
|
156
|
+
|
|
157
|
+
**Gate.** Check 1 failing means there is no patch to rate: `lint` / `behavior` / `scope` all 0, `confidence: high`, whatever the dry-run did. Regression mode exempts a source run that `apply.log` records as skipped or narrowed per the reviewer hint.
|
|
158
|
+
|
|
159
|
+
**Caps** (gate passed, applied after picking the anchor):
|
|
160
|
+
|
|
161
|
+
| Finding | `behavior` at most | `confidence` at most |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| Dry-run didn't use the patch | 50 | low |
|
|
164
|
+
| Probe `leading` | 50 | medium |
|
|
165
|
+
| Probe `easier` | 70 | medium |
|
|
166
|
+
| Probe `off_target` | 50 | low |
|
|
167
|
+
| Probe in another language | — | medium |
|
|
168
|
+
|
|
169
|
+
A capped 50 means "no evidence either way" and never reads as a regression.
|
|
170
|
+
|
|
171
|
+
**Comparison.** The scorer describes the baseline turn and the dry-run each on its own (quoting a line or naming a tool call) before comparing them; only the record counts (plans, promises and unrun code don't), length and polish don't, scores are relative to the baseline (doing what the baseline already did = 50), and new errors the baseline didn't make count against the patch.
|
|
172
|
+
|
|
173
|
+
Dimensions — values restricted to the anchors **100 / 70 / 50 / 30 / 0** (torn between two → the lower one, reason in `notes`):
|
|
174
|
+
- **lint**: patched file parses and a whole-folder override carries every file of the global folder; 0 on gate failure, empty dry-run output, unparseable file, or an `agents/<id>/` override without `agent.yaml`.
|
|
175
|
+
- **behavior**: dry-run better than the baseline on the situation the rule targets; 100 needs check 2 passed, a `valid` probe, a clear gain and no new error; 50 = no difference / trade-off / capped.
|
|
176
|
+
- **scope**: blast radius of the actual diff seen in check 1 (verbatim copies into a folder override don't count) — 100 = one file ≤5 lines, down to 0 = whole AGENT.md rewrite or unrelated files.
|
|
177
|
+
- **confidence**: set by the evidence, then capped by the table; a gate failure is `high`.
|
|
178
|
+
|
|
179
|
+
`avg = round((lint + behavior + scope) / 3)` — single sort key for admin UI. `checks` is additive: the wrapper and admin read only the numeric fields + `confidence` / `notes`, so older score.json files without it still parse.
|
|
148
180
|
|
|
149
|
-
|
|
181
|
+
The rubric structure (labelled anchors, evidence before verdict, claims are not evidence) follows the AWS AgentCore Evaluations built-in evaluator templates.
|
|
150
182
|
|
|
151
183
|
Scorer is read+write only (`file_read` / `file_write` / `file_list` / `grep` / `glob`); no execution / no shell. Outputs only `score.json`. Same scorer agent reused at apply time (phase B') as regression gate.
|
|
152
184
|
|
|
@@ -174,7 +206,7 @@ The wrapper clears any stale ABORT.md (from a crashed previous attempt) at the s
|
|
|
174
206
|
For each source_run_id:
|
|
175
207
|
|
|
176
208
|
1. Read `testScenario` from source run's `patch.md`.
|
|
177
|
-
2. Run `halo cli -a <agentId> -n -w <applyDir>/sandbox --access workspace` (testMessage on stdin, same
|
|
209
|
+
2. Run `halo cli -a <agentId> -n -w <applyDir>/sandbox --access workspace` (testMessage on stdin, same 2700s timer) against merged sandbox.
|
|
178
210
|
3. Spawn `__score__` with regress-mode brief, write `regress/<runId>/score.json`.
|
|
179
211
|
4. Any score with `lint < 50` or `behavior < 50` (or a missing / malformed `score.json`) → regression, abort with `failed`.
|
|
180
212
|
|
|
@@ -201,7 +233,7 @@ Per-workspace mutex in ticker prevents two applies from racing on the same works
|
|
|
201
233
|
images/<msgIdx>-<blockIdx>.<ext> # decoded base64 images
|
|
202
234
|
patch.md # __evo_agent__ writes (has testScenario frontmatter)
|
|
203
235
|
.skip.md # evo wrote this → no patch proposed
|
|
204
|
-
score.json # __score__ writes (lint/behavior/scope/avg)
|
|
236
|
+
score.json # __score__ writes (lint/behavior/scope/avg + checks)
|
|
205
237
|
dry-run-output.txt # stdout from phase B dry-run
|
|
206
238
|
dry-run-fail-<n>.log # failure logs from dry-run attempts
|
|
207
239
|
sub-cli.log # tee'd stdout+stderr of every halo cli spawn
|
|
@@ -23,6 +23,8 @@ canvas animation (visible to user)
|
|
|
23
23
|
|
|
24
24
|
The marker is **stripped from rendered chat** so the user sees only the face moving, not the code driving it.
|
|
25
25
|
|
|
26
|
+
The channel is two-way. The face reports back to its host (`parent.postMessage`) — one-line **receipts** of what ran / failed / was clicked, and `self.snap()` **screenshots** — see [Face → admin messages](#face--admin-messages-receipts-and-snapshots). The admin side (the ✨ switch, the `[Face open: …]` tag, receipt injection, snapshot return) is described in [requirements/chat.md](../requirements/chat.md).
|
|
27
|
+
|
|
26
28
|
## Data flow
|
|
27
29
|
|
|
28
30
|
**Seeding:** `init.ts` calls `ensureWorkspaceHalo()` on workspace open, force-copying the canonical engine from `packages/server/templates/canvas/self.html` to `<workspace>/.halo/canvas/self.html`.
|
|
@@ -42,15 +44,14 @@ The marker is **stripped from rendered chat** so the user sees only the face mov
|
|
|
42
44
|
el.contentWindow?.postMessage({ haloFace: payload }, '*')
|
|
43
45
|
```
|
|
44
46
|
|
|
45
|
-
**Reception:** `self.html` listens on `window.message
|
|
47
|
+
**Reception:** `self.html` listens on `window.message` and hands the code to `runCode()` — the one function every JS entry goes through (the message door and voice-cue `js`):
|
|
46
48
|
```javascript
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
catch (err) { /* malformed line no-ops */ }
|
|
52
|
-
})
|
|
49
|
+
function runCode(code) {
|
|
50
|
+
try { (new Function('self', code))(self); ack('js ok') }
|
|
51
|
+
catch (err) { ack('js err: ' + String(err?.message || err).slice(0, 60)) }
|
|
52
|
+
}
|
|
53
53
|
```
|
|
54
|
+
A malformed line still no-ops for the face, but the host now hears about it.
|
|
54
55
|
|
|
55
56
|
**UI stripping:** `message-list.tsx:TextBlock()` strips both markers before render:
|
|
56
57
|
```javascript
|
|
@@ -64,20 +65,95 @@ Provided by `self.html` as `const self = {…}`. All expression methods are sand
|
|
|
64
65
|
|
|
65
66
|
### Scene queuing (sequential playback)
|
|
66
67
|
|
|
67
|
-
- `self.say(text, ms)` — form `text` (emoji→ASCII), hold `ms` (default 3600), dissolve back to breathing. Enqueued.
|
|
68
|
-
- `self.play(score)` — choreograph a sequence of beats: `[{say, hold, pulse, flash, shake, rest, gap}, ...]
|
|
69
|
-
- `self.intro()` — built-in opening: "
|
|
68
|
+
- `self.say(text, ms)` — form `text` (emoji→ASCII), hold `ms` (default 3600), dissolve back to breathing. Enqueued. `self.say(text, ms, {pos})` puts it in a margin cell instead — see [Nine-grid](#nine-grid-margin-cells-pos).
|
|
69
|
+
- `self.play(score)` — choreograph a sequence of beats: `[{say, show, pos, hold, pulse, flash, shake, snap, rest, gap}, ...]` (`show` = an image, see [Image](#image-show--a-picture-gathered-out-of-the-dots); `pos` on a say/show beat sends it to a margin cell and the beat does not wait; `snap` = [`self.snap()`](#snap--look-at-myself)). Each beat waits for the engine's internal clock. Enqueued; calling play() again appends to the queue rather than cancelling it.
|
|
70
|
+
- `self.intro()` — built-in opening, one subtitle in the language the admin last sent (`haloFaceLang`, default `en`): `play([{say:"Hi, I'm Halo.", hold:2200}, {rest:true}])`, or for `zh` `{say:"你好,我是 Halo。", hold:2000}` — no pulse, no voice. English: ≈0.55 s to 90 % formed, ≈1.5 s fully legible, dissolve back home by ≈2.7 s (95 % of dots within 10 % of their start distance; 95 % within 5 px by ≈3.1–3.2 s). Chinese: same 0.55 s gather, released at 2.03 s, home by ≈2.53 s (95 % within 5 px by ≈2.9–3.1 s) — 7 glyphs of a familiar greeting read faster than the English line, so it holds 200 ms less. Triggered by whoever opens the face (the admin posts it on the face iframe's `load` after the ✨ toggle is switched on; a tab restored on refresh stays quiet), **not** self-fired on page load — a self-fired load intro raced the button's post and played the greeting twice on first open. It names Halo (the product), never a model — the agent's conversational identity stays user-configurable.
|
|
70
71
|
|
|
71
72
|
### Instant gestures (overlays, never queued)
|
|
72
73
|
|
|
73
74
|
- `self.pulse()` — one bright ripple from the core (acknowledgement).
|
|
74
75
|
- `self.flash(n)` — hot flicker of the whole field (emphasis). `n` scales duration.
|
|
75
76
|
- `self.shake(ms)` — lateral tremor (negation, error). Default 500ms.
|
|
76
|
-
- `self.rest()` — return to calm breathing immediately, clear queue; also stops a playing voice clip.
|
|
77
|
+
- `self.rest()` — return to calm breathing immediately, clear queue (and cancel the running scene's remaining beats); also stops a playing voice clip (and drops a voice that was waiting for a click) and removes the centre image and every margin picture / line at once.
|
|
78
|
+
- `self.clear(pos?)` — take down the margin cell `pos`; with no argument, all margin cells. The centre picture is not touched (it ends on its own, or by click / next scene).
|
|
77
79
|
|
|
78
80
|
### Voice (live audio — mode `wave`)
|
|
79
81
|
|
|
80
82
|
- `self.voice(path)` — play a speech clip Halo synthesized and ride its **live** amplitude via a Web Audio `AnalyserNode`: loudness swells the core, a 6-band spectrum grows directional petals (timbre has a shape, not just a size), each syllable onset spawns a ring. Halo synthesizes the audio; the face only makes it visible — silent audio yields a calm face, never a canned animation. `path` is the **workspace path** of the audio file (mp3/wav/m4a/ogg); the engine resolves it to `/api/files/download?path=…&projectId=…&inline=1` using the `projectId` already in its own iframe `src`, so the agent never builds a URL or knows the projectId (a full `http(s)://` / `/api/…` URL also passes through unchanged). Voice clips queue with each other — successive `voice()` calls play in sequence — but bypass the scene queue: the first call enters mode `wave` and owns the matrix until the queue drains (or `rest()` clears it). When the queue is empty the face eases back to breathing.
|
|
83
|
+
- **Blocked by autoplay → kept, not dropped.** The browser may refuse `el.play()` (`NotAllowedError`) when the page has had no user gesture — in the admin the clip is triggered by a postMessage, and `allow="autoplay"` on the iframe does not help if the top page has never been touched. The blocked clip **stays at the head of the queue**; `voice.blocked = true` and `voice blocked (needs a click)` is acked once. The next `pointerdown` on the page replays it (the gesture lets us `ctx.resume()` + `play()`); that press is **only** the permission — it closes nothing — and acks `user click → voice resumed`, then the usual `voice <name> playing`. Other `play()` failures (bad source) ack `voice <name> fail` and move to the next clip. `rest()` clears a waiting clip.
|
|
84
|
+
- **Cues:** `self.voice(path, {cues:[{at, say?, js?}]})`. Cues are sorted by `at`; every frame `runCues()` compares `el.currentTime` with the next cue's `at` and fires each cue exactly once (a seek back doesn't re-fire). `js` goes through `runCode()` (same receipts as the message door). `say` calls `sayNow()` with a hold of `min(gap to next cue, 2.4 s)` (last cue 1.8 s): the matrix switches `wave → text`, and when the hold lapses `toRest()` hands it back to `wave` because `toRest()` is voice-aware (`mode = voice.playing ? 'wave' : 'rest'`). The audio is never touched — only the field is borrowed between swells, and `voice.level` keeps brightening the lettering. Chosen over overlaying text on the live waveform because it reuses the existing glyph sampler untouched and reads cleanly; the cost is that the ripple pauses while a cue's word is up. Halo has no TTS clock, so the `at` times come from the agent (clip duration, or the synthesizer's timestamps such as Polly speech marks).
|
|
85
|
+
- **Acks:** `voice <file> playing` (first `playing` event of a clip), `voice <file> ended <s>s` (duration, 1 decimal), `voice <file> fail`.
|
|
86
|
+
|
|
87
|
+
### Image (`show` — a picture gathered out of the dots)
|
|
88
|
+
|
|
89
|
+
- `self.show(path, ms)` — put a workspace image on the face. Enqueued as a one-beat scene, so it plays in order with `say()` / `play()`; inside `play()` it is the `{show: path, hold, gap}` beat (`hold` = how long the image stays fully visible, `gap` = pause after it is gone). `path` is the workspace path, resolved by the same `resolveFileUrl()` as `voice()` (`/api/files/download?path=…&projectId=…&inline=1`, which already serves images incl. svg with an image MIME — no server change). Formats: png / jpg / jpeg / webp / gif / svg. `.excalidraw` / `.drawio` are editor sources and are **not** supported (no editor libs in `self.html`) — for a diagram the agent writes an SVG and shows that.
|
|
90
|
+
- **Timeline:** the dots fly in to the image's outline (`PIC_GATHER` 1.0 s) → the real image fades in over them (`PIC_FADE` 0.6 s) → fully visible for `ms` (default `PIC_HOLD` 6000; `Infinity` = until the next scene is enqueued, `rest()`, or a click/tap on the page) → fades out (0.6 s) while the dots let go and ease home; the next beat starts after the fade. The image is a real DOM `<img class="pic">` over the canvas (fit ≈80% of the viewport, aspect preserved, centered; re-laid out on resize) — dots alone (one per ~7 px) can't carry chart text, so the overlay is mandatory.
|
|
91
|
+
- **Outline sampling (`sampleImage`):** a photo has no alpha, so the text sampler's "alpha > 128" would fill the rectangle. Instead the image is drawn to the offscreen sampler canvas and a 7 px grid point is kept when it sits on an **edge** (channel difference > 48 against its left/right/up/down neighbour one step away); fewer than 120 edge points falls back to the opaque area, and a tainted canvas (cross-origin URL) to a plain frame of dots. `assignTargets()` then hands those points to the particles; the particles on the outline (`p.pic`) hold their place with a faint shimmer and stay lit.
|
|
92
|
+
- **Independent of the particle mode:** the gather/hold step (`holdPic`) runs after the mode's own targets, so a `self.voice()` clip keeps rippling the field (the outline dots are nudged, not pulled off the picture) and the image timers are plain timeouts — a voice taking mode `wave` doesn't end the picture.
|
|
93
|
+
- **Failure never stalls the queue:** 404, a non-image body or a decode error → `console.warn('[self] show failed: …')`, a small `self.shake()`, and the beat is skipped. An SVG with no intrinsic size (`naturalWidth/Height` 0) gets the whole 80 % box (`object-fit: contain` keeps its aspect).
|
|
94
|
+
- One centre picture at a time; `QUEUE_MAX` and the drop-oldest rule apply as for any scene.
|
|
95
|
+
- **Acks:** `show <path> ok` once loaded, `show <path> fail` on load failure (path tail-clipped to fit the 80-char ack).
|
|
96
|
+
- **Infinity and the queue:** unchanged — an `Infinity` picture holds its beat until it leaves, and the next scene enqueued (`enqueueScene`) lets it go. A `self.snap()` is the exception: it is enqueued with `keepPic`, so it looks at the picture instead of dismissing it (the beat in front of it hands it the turn once the picture is fully in).
|
|
97
|
+
- **Click:** a press on an `Infinity` centre picture closes it from anywhere (as before); a press on a finite centre picture closes it when the press lands on it. Either acks `user closed c`.
|
|
98
|
+
|
|
99
|
+
### Nine-grid (margin cells, `pos`)
|
|
100
|
+
|
|
101
|
+
`self.show(path, ms, {pos})` and `self.say(text, ms, {pos})`, `pos` ∈ `tl t tr l r bl b br` (`c` / absent = the centre behaviour above).
|
|
102
|
+
|
|
103
|
+
- **Not scenes.** A margin show/say appears **at once**, never enters `sceneQueue`, and never blocks the say/show after it (inside `play()` the beat dwells ~0.6 s and moves on). One occupant per cell — a new one in the same cell replaces the old.
|
|
104
|
+
- **Plain centre `show()` clears every margin cell** (immediately on the call, not when its beat comes up); a plain centre `say()` leaves them. `rest()` clears all; `self.clear(pos?)` one or all. Each leaves with the same 0.6 s fade as the centre.
|
|
105
|
+
- **Geometry (`cellRect`):** the cell box is `PIC_FIT × 0.4` of the viewport (32 % × 32 %), pinned to its edge/corner with a 16 px margin (44 px at the bottom, clearing the status readout); `l`/`r` centre vertically, `t`/`b` horizontally. The `<img class="pic corner">` is fitted inside the box (aspect kept) and anchored toward its edge. `ms` defaults to `PIC_HOLD`; `Infinity` stays. All cells are re-laid out on resize.
|
|
106
|
+
- **Dots:** a cell *claims* free particles (`p.slot`): ~10 % of the pool at most (`min(10 % of the pool, perimeter/6)`) and marches them slowly round the picture's frame (`holdSlots`, after the mode and `holdPic`). The claimed dots are excluded from the centre's pool in `assignTargets`, so a centre word/picture is dealt from what is left; whenever a cell appears or leaves, `refreshCenter()` re-deals the centre word (`centerPts`) / picture so the two never fight over a dot. Released dots are sent home explicitly (text mode doesn't retarget every frame).
|
|
107
|
+
- **Corner text:** drawn as small dot-type, not DOM — `sampleText(text, box)` samples the line into the cell box at a finer 4 px step (the centre uses `max(4, min(7, round(fontPx / 9)))` — 7 px for a short word, finer for a long line in a narrow pane; text containing CJK uses `fontPx / 16`, since one glyph packs many strokes, and a centre line wider than 80 % of the pane — only CJK, whose glyphs are two mono cells wide — is shrunk to fit), capped at ~16 % of the pool, and the claimed dots form the letters at a smaller radius (2.1 px). Same engine, same look as the centre word, zero DOM; the price is that long lines get sparse, so keep corner text to a word.
|
|
108
|
+
- **Failure:** load error → `console.warn`, `self.shake()`, `show <path>@<pos> fail`; nothing else is affected.
|
|
109
|
+
- **Acks:** `show <path>@tr ok` / `fail`.
|
|
110
|
+
- **Click hit-testing:** a `pointerdown` is tested against margin pictures first, then the centre picture. A hit closes only that one and acks `user closed <pos>` (`c` for the centre). A press that hits nothing acks `user click` (and, for an `Infinity` centre picture, still closes it as before).
|
|
111
|
+
|
|
112
|
+
### Snap (look at myself)
|
|
113
|
+
|
|
114
|
+
- `self.snap()` — one beat in the scene queue (`{snap:true}` inside `play()`). When its turn comes `snapNow()` composites an offscreen canvas at ≤ 1280 px on the long side: the page background (`#070a16`, or the theme's `background`), the main canvas (dots **and** `say` lettering — it's drawn there, nothing extra to paint), then the centre `<img>` and each margin `<img>` via `drawImage` at their live `getBoundingClientRect()` and computed opacity (backed like `.pic`: white, or the theme's `card`). It exports JPEG q 0.85 and posts `{haloFaceSnap:{data, mimeType:'image/jpeg'}}` (base64 without the `data:` prefix), then the queue moves on immediately (0 dwell).
|
|
115
|
+
- **Waiting for what it should see:** the snap is taken while the beat in front of it is fully on screen (word formed after 1.8 s; centre picture fully faded in; margin cell ~1.4 s), not after it has gone — so `self.say("OK"); self.snap()` returns the finished word. With the queue idle, `snap()` runs at once. A snap does not count as a "next scene" for an `Infinity` picture. If a margin picture is still loading/flying in, `snapNow()` retries every 100 ms for up to 3 s.
|
|
116
|
+
- **Failure:** a cross-origin image taints the offscreen canvas; `toDataURL` throws → `snap fail` ack and no `haloFaceSnap`. Standalone (no parent) it is a no-op that posts nothing.
|
|
117
|
+
- Once-per-round and the anti-chain rule are the admin's job (face posts once per call).
|
|
118
|
+
|
|
119
|
+
## Face → admin messages (receipts and snapshots)
|
|
120
|
+
|
|
121
|
+
The face talks to its host with `window.parent.postMessage(msg, '*')`. When `parent === window` (page opened directly) it posts nothing. The admin only accepts messages whose `e.source` is a registered face iframe's `contentWindow`.
|
|
122
|
+
|
|
123
|
+
| Message | Shape | When |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| Receipt | `{haloFaceAck: '<text ≤ 80 chars>'}` | each event below |
|
|
126
|
+
| Snapshot | `{haloFaceSnap: {data: '<base64>', mimeType: 'image/jpeg'}}` | `self.snap()` produced an image |
|
|
127
|
+
|
|
128
|
+
Receipt texts (the admin treats them as opaque: dedupe, keep the last 8, join with `, `):
|
|
129
|
+
|
|
130
|
+
| Text | Meaning |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `js ok` | a `haloFace` line (or a cue's `js`) ran without throwing |
|
|
133
|
+
| `js err: <message, ≤ 60 chars>` | it threw (syntax error, undefined name, …) |
|
|
134
|
+
| `show <path> ok` / `show <path> fail` | centre image loaded / failed to load (404, not an image) |
|
|
135
|
+
| `show <path>@<pos> ok` / `… fail` | the same for a margin cell |
|
|
136
|
+
| `voice <file> playing` | the clip started |
|
|
137
|
+
| `voice blocked (needs a click)` | autoplay refused; clip kept, sent once per block |
|
|
138
|
+
| `voice <file> ended <n.n>s` | the clip finished (its duration) |
|
|
139
|
+
| `voice <file> fail` | source error / non-autoplay `play()` failure |
|
|
140
|
+
| `snap fail` | export failed (tainted canvas); no snapshot posted |
|
|
141
|
+
| `user click` | a press on the face that hit nothing and resumed nothing |
|
|
142
|
+
| `user closed <pos>` | the press closed a picture (`c` = centre, else the cell) |
|
|
143
|
+
| `user click → voice resumed` | the press replayed a blocked clip |
|
|
144
|
+
|
|
145
|
+
Paths and file names are tail-clipped (`…`) so the line stays within 80 characters.
|
|
146
|
+
|
|
147
|
+
The admin turns the queued receipts into the `· last: …` part of the `[Face open: …]` tag on the user's next message; they never wake the agent — see [requirements/chat.md](../requirements/chat.md).
|
|
148
|
+
|
|
149
|
+
## Face theme (admin → face)
|
|
150
|
+
|
|
151
|
+
- **Message:** `{haloFaceTheme: {scheme: 'light'|'dark', vars}}` — `vars` is the admin's semantic palette (the 16 `EXTENSION_THEME_TOKENS`, values as globals.css declares them), `scheme` is derived from the rendered `--background` luminance (> 0.4 → light). Both come from `shared/theme/palette.ts:readHostTheme()`, the same reader canvas extensions get their `init` / `theme` palette from.
|
|
152
|
+
- **When:** `face-bridge.ts:postFaceTheme(el)` — from `faceLoaded(el)` on every face iframe `load`, **before** the intro (posts to one window arrive in order, so the opening already wears the theme); and from `html-preview.tsx` (face only) on every `useTheme().theme` change. The face iframe's own background is `var(--background)` so nothing flashes white before load.
|
|
153
|
+
- **Face side:** `setTheme()` converts each colour to sRGB through a 1×1 canvas (hex / `rgb()` / `oklch()` alike) and derives the whole palette — never from a theme name, so a theme added to globals.css just works: ramp = 5 stops interpolated from `background` → `primary` → `primary` mixed toward `foreground` (warmth's violet nudge kept as the same relative offset); dark → `'lighter'` blending over a `card` → `background` radial wash; light → `'source-over'` (additive light vanishes on a pale page), dots deepened toward `foreground` with alpha `v^1.8` so the resting field is a faint wash and lit dots go solid, wash `background` with a hint of `primary`. Status readout = `muted-foreground`; `.pic` mat = `card`, its glow `primary` at 0.35 / 0.3 alpha; `snap()` uses the same background and mat. Only colours change — the grid, the scene queue and anything playing (say / show / voice) carry on.
|
|
154
|
+
- **Fallback:** no message (opened standalone, or an older admin), or one missing `background` / `primary` / `foreground` / with an unparseable value → the face keeps what it wears; untouched, that is the original look (`#070a16`, the blue ramp, `'lighter'`) pixel for pixel.
|
|
155
|
+
- **No receipt:** a theme message is not the agent's doing, so it posts no `haloFaceAck`.
|
|
156
|
+
- **Language:** `{haloFaceLang: 'zh'|'en'}` — `face-bridge.ts:postFaceLang(el, lang)`, sent by `faceLoaded(el, lang)` right after the theme and before the intro, and by `html-preview.tsx` (face only) on every `useI18n().lang` change. The face only remembers it for `intro()` (anything but `'zh'` → `'en'`; never sent → `'en'`, the old behaviour): a switch replays nothing and posts no receipt.
|
|
81
157
|
|
|
82
158
|
### Reactions (named vocabulary)
|
|
83
159
|
|
|
@@ -85,15 +161,15 @@ Provided by `self.html` as `const self = {…}`. All expression methods are sand
|
|
|
85
161
|
|
|
86
162
|
### Introspection
|
|
87
163
|
|
|
88
|
-
- `self.state` — read `{mode, awake, W, H, speaking, level}` (current mode, attention level 0..1, viewport dims, whether a voice clip is playing,
|
|
164
|
+
- `self.state` — read `{mode, awake, W, H, speaking, level, showing}` (current mode, attention level 0..1, viewport dims, whether a voice clip is playing, its live loudness 0..1, and whether an image is on the face).
|
|
89
165
|
|
|
90
166
|
## Key files
|
|
91
167
|
|
|
92
|
-
- **Engine template:** `packages/server/templates/canvas/self.html` — particle field, mode switching, API surface, voice audio graph. Canonical source; force-copied to every workspace on open.
|
|
168
|
+
- **Engine template:** `packages/server/templates/canvas/self.html` — particle field, mode switching, API surface, voice audio graph (blocked-voice resume, cues), image overlay (`show`), nine-grid margin cells, `snap`, host receipts. Canonical source; force-copied to every workspace on open.
|
|
93
169
|
- **Skill instruction:** `packages/server/templates/skills/self/SKILL.md` — teaches the agent when/how to use the face.
|
|
94
170
|
- **Marker detection:** `packages/admin/src/shared/ws-handlers/chat-handlers.ts:maybeHandleShow()` — regex match `<<<SHOW:([\s\S]*?)>>>` on the round's replies at `chat:complete`; the replies come from `takeRoundReplies()` in `chat-store.ts`, which hands each bubble out once.
|
|
95
|
-
- **Iframe registration:** `packages/admin/src/features/editor/face-bridge.ts` — module-level registry of mounted previews; `postToFace()` forwards payloads via `postMessage
|
|
96
|
-
- **Preview component:** `packages/admin/src/features/editor/html-preview.tsx` — sandboxed iframe with `allow-scripts` + `allow-same-origin` and `allow="autoplay"` (so `self.voice` audio, triggered by postMessage rather than a click, isn't gated)
|
|
171
|
+
- **Iframe registration:** `packages/admin/src/features/editor/face-bridge.ts` — module-level registry of mounted previews; `postToFace()` forwards payloads via `postMessage`; `postFaceTheme()` / `postFaceLang()` send the palette and UI language; `faceLoaded(el, lang)` runs on iframe `load` and posts theme → language → (if requested) `self.intro()`, in that order.
|
|
172
|
+
- **Preview component:** `packages/admin/src/features/editor/html-preview.tsx` — sandboxed iframe with `allow-scripts` + `allow-same-origin` and `allow="autoplay"` (so `self.voice` audio, triggered by postMessage rather than a click, isn't gated). For the face it calls `registerFaceIframe()` on mount, `faceLoaded()` from `onLoad`, and re-posts the theme on every `useTheme().theme` change and the language on every `useI18n().lang` change (a language switch replays no intro).
|
|
97
173
|
- **Marker stripping:** `packages/admin/src/shared/components/message-list.tsx:TextBlock()` — strips both `<<<CAPTURE>>>` and `<<<SHOW:...>>>` before rendering.
|
|
98
174
|
- **Workspace init:** `packages/server/src/init.ts:ensureWorkspaceHalo()` — force-copies engine on workspace open. `self` is in `BUILTIN_SKILL_IDS` so the skill is always available.
|
|
99
175
|
|
|
@@ -102,7 +178,7 @@ Provided by `self.html` as `const self = {…}`. All expression methods are sand
|
|
|
102
178
|
The face is a fixed grid of particles. Each knows its current position and a target position, easing between them every frame.
|
|
103
179
|
|
|
104
180
|
- **Grid:** 22px spacing, 60fps animation loop
|
|
105
|
-
- **Modes:** `rest` (breathing grid), `text` (forming letters), `wave` (particles ride live audio amplitude during `self.voice`)
|
|
181
|
+
- **Modes:** `rest` (breathing grid), `text` (forming letters), `wave` (particles ride live audio amplitude during `self.voice`). An image shown by `self.show` is **not** a mode: it is a DOM overlay plus `holdPic()` (outline dots pinned to the picture), layered on whatever mode is current. Margin cells likewise (`holdSlots()`).
|
|
106
182
|
- **Glyph sampling:** Text→offscreen canvas→pixel alpha sampling→nearest-particle assignment (greedy scan with shuffle for repeated words)
|
|
107
183
|
- **Emoji accent:** Maps common emoji to ASCII (`👍`→`+1`, `❤`→`<3`, etc.) so the monospace aesthetic stays consistent; anything untranslated is stripped
|
|
108
184
|
- **Attention:** Eases toward higher values when the cursor is on the canvas (gaze tracking); particles brighten and the core warmth shifts slightly toward violet
|
|
@@ -116,10 +192,10 @@ The marker is a **verbatim pipe** — Halo never parses or validates the payload
|
|
|
116
192
|
|
|
117
193
|
- **Format:** `<<<SHOW: <js> >>>` where `<js>` is a complete JavaScript expression or statement
|
|
118
194
|
- **Scope:** The code runs in a function closure with `self` as the API surface: `(new Function('self', code))(self)`
|
|
119
|
-
- **Errors:** Non-greedy pattern `[\s\S]*?` handles newlines and nested `>` characters. Malformed lines
|
|
195
|
+
- **Errors:** Non-greedy pattern `[\s\S]*?` handles newlines and nested `>` characters. Malformed lines no-op on the face (caught in try/catch) and send a `js err: …` receipt to the host.
|
|
120
196
|
- **Order:** Multiple markers in one reply are extracted in sequence and forwarded in order; scene beats queue and play sequentially
|
|
121
197
|
- **Once-only:** Each marker fires exactly once — `takeRoundReplies()` hands every bubble out a single time, so duplicate queue-drain `chat:complete` events find nothing new, and markers in loaded history never fire
|
|
122
|
-
- **Window:** Markers are dropped silently if no face preview is open; the registry is empty so `postToFace()` has no targets
|
|
198
|
+
- **Window:** Markers are dropped silently if no face preview is open; the registry is empty so `postToFace()` has no targets. The agent learns whether the face is open from the `[Face open: …]` tag the admin puts on each message (admin side: [requirements/chat.md](../requirements/chat.md)).
|
|
123
199
|
|
|
124
200
|
### Trust model — why `new Function` is acceptable here
|
|
125
201
|
|
|
@@ -155,6 +231,6 @@ From `self/SKILL.md`:
|
|
|
155
231
|
|
|
156
232
|
## Scope and out-of-scope
|
|
157
233
|
|
|
158
|
-
Supported: all `self` API calls (say/play/intro/react/pulse/flash/shake/voice/rest); queue management; particle animation; attention/gaze tracking; CJK text; emoji-to-ASCII translation; live voice playback with amplitude-driven waveform (mode `wave`).
|
|
234
|
+
Supported: all `self` API calls (say/play/intro/react/pulse/flash/shake/voice/show/clear/snap/rest); margin cells (`pos`); voice cues; blocked-voice resume; receipts and snapshots to the host; queue management; particle animation; attention/gaze tracking; CJK text; emoji-to-ASCII translation; live voice playback with amplitude-driven waveform (mode `wave`); workspace images (png/jpg/webp/gif/svg) gathered out of the dots and shown in full (`show`).
|
|
159
235
|
|
|
160
|
-
Not supported: TTS synthesis itself (Halo produces the audio; the face only plays a given URL); file editing of the engine; escape from sandbox; custom particle physics.
|
|
236
|
+
Not supported: TTS synthesis itself (Halo produces the audio; the face only plays a given URL); editor-source diagrams (`.excalidraw` / `.drawio` — write an SVG instead); file editing of the engine; escape from sandbox; custom particle physics.
|
|
@@ -16,8 +16,10 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
16
16
|
│ ├── agents/<id>/
|
|
17
17
|
│ │ ├── agent.yaml # Agent config
|
|
18
18
|
│ │ └── AGENT.md # Agent personality
|
|
19
|
-
│ ├── models/ # Model registry — one file per provider,
|
|
19
|
+
│ ├── models/ # Model registry, bundled — one file per provider, overwritten on template refresh
|
|
20
20
|
│ │ └── <providerId>.yaml # e.g. aws-bedrock-claude-invoke.yaml
|
|
21
|
+
│ ├── models.d/ # Hub-installed provider yamls (`/extension models`); never touched by seeding
|
|
22
|
+
│ │ └── <providerId>.yaml # merged with models/ by revision (see Model registry format)
|
|
21
23
|
│ ├── skills/<id>/
|
|
22
24
|
│ │ └── SKILL.md # Skill definition (frontmatter + body)
|
|
23
25
|
│ ├── internal-sessions/<agentId>/ # Internal-agent sessions (`__evo_agent__`, `__score__`, `__apply_agent__`)
|
|
@@ -368,6 +370,7 @@ Path conventions:
|
|
|
368
370
|
- `<provider-id>.secrets.<key>` — server-side credentials for a model provider
|
|
369
371
|
- `<skill-id>.params.<key>` — values an agent can reference via `{{<skill-id>.params.<key>}}`
|
|
370
372
|
- `<skill-id>.secrets.<key>` — server-side keys a skill needs but agents must not see
|
|
373
|
+
- `ext-<extension-id>.params.<key>` / `ext-<extension-id>.secrets.<key>` — a canvas extension's manifest-declared `settings` (global file only, read by server code such as the transcription proxy; never by the extension itself) — see [canvas-extensions.md → Extension settings](canvas-extensions.md#extension-settings)
|
|
371
374
|
|
|
372
375
|
### Load precedence
|
|
373
376
|
|
|
@@ -420,12 +423,17 @@ Values present in `settings.yaml` whose namespace isn't currently declared by an
|
|
|
420
423
|
|
|
421
424
|
## Model registry format
|
|
422
425
|
|
|
423
|
-
|
|
426
|
+
Paths: `~/.halo/global/models/<providerId>.yaml` (bundled, seeded from `templates/models/` and overwritten on every template refresh) and `~/.halo/global/models.d/<providerId>.yaml` (hub-installed by `halo models install` / `/extension models`; seeding and refresh never touch it) — one file per provider. Both dirs are read on first access and merged into the in-memory registry. The same yaml also declares the provider's required server-side credentials (`secrets:` section), which the Settings page renders.
|
|
427
|
+
|
|
428
|
+
**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.
|
|
429
|
+
|
|
430
|
+
**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.
|
|
424
431
|
|
|
425
432
|
```yaml
|
|
426
433
|
# aws-bedrock-claude-invoke.yaml
|
|
427
434
|
id: aws-bedrock-claude-invoke # required, must match filename and agent.yaml model.provider
|
|
428
435
|
runtime: bedrock-invoke # required, implementation class (wire protocol) — see below
|
|
436
|
+
revision: 2026100501 # integer YYYYMMDDNN — bump on every edit; absent = 0
|
|
429
437
|
displayName: AWS Bedrock Claude (Invoke API) # shown in UI
|
|
430
438
|
displayName_zh: AWS Bedrock Claude(Invoke API) # optional zh override
|
|
431
439
|
description: Invokes Bedrock via InvokeModel (non-streaming)
|
|
@@ -140,6 +140,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
|
|
|
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
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). |
|
|
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
|
+
| `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). |
|
|
143
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). |
|
|
144
145
|
|
|
145
146
|
### `error` frames: optional `code`
|
|
@@ -160,6 +160,7 @@ Create `packages/server/templates/models/<provider>.yaml` (deployed to `~/.halo/
|
|
|
160
160
|
```yaml
|
|
161
161
|
id: myprovider # required, must match filename and agent.yaml model.provider
|
|
162
162
|
runtime: myprovider # required, implementation class — an existing name (e.g. openai-chat) or your new one
|
|
163
|
+
revision: 2026100501 # required for bundled / hub yamls — integer YYYYMMDDNN, bump on every edit
|
|
163
164
|
displayName: My Provider
|
|
164
165
|
description: Short description
|
|
165
166
|
defaultEndpoint: https://api.example.com/v1
|
|
@@ -193,15 +194,23 @@ models:
|
|
|
193
194
|
|
|
194
195
|
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).
|
|
195
196
|
|
|
197
|
+
**`revision` decides which copy is in effect.** The same provider yaml can exist twice: the bundled copy (`templates/models/` → `~/.halo/global/models/`, overwritten on every template refresh) and a hub copy (`~/.halo/global/models.d/<id>.yaml`, installed by `/extension models` from the hub's `models-v*` release, never touched by seeding). Per `id` the higher `revision` wins; on a tie the `models.d` copy wins; a yaml without `revision` counts as 0 (a user's own custom yaml in `models/`). The merge lives in one place, [models/registry.ts](../../../packages/server/src/models/registry.ts) (`loadProviders`), behind the server registry, the Settings secrets forms, `halo setup` and the hub installer. **Bump `revision` on every edit — bundled or hub — or the edit loses to the other copy.** Use the edit date: `YYYYMMDD` + a two-digit sequence for that day.
|
|
198
|
+
|
|
199
|
+
### Distributing a provider change through the hub
|
|
200
|
+
|
|
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 or a changed `defaultEndpoint` / `endpointPresets` 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
|
+
|
|
196
203
|
### 3. Wire the dispatcher
|
|
197
204
|
|
|
198
|
-
Skip this step when the manifest's `runtime:` names an existing runtime. For a new one,
|
|
205
|
+
Skip this step when the manifest's `runtime:` names an existing runtime. For a new one, add the name to `MODEL_RUNTIME_NAMES` in [packages/server/src/models/registry.ts](../../../packages/server/src/models/registry.ts) (kept there, dependency-free, so `halo models install` can validate against it; `model-runtime.ts` re-exports it), then the import and switch case in [packages/server/src/agents/model-runtime.ts](../../../packages/server/src/agents/model-runtime.ts) (keyed by the runtime name, not the provider id):
|
|
199
206
|
|
|
200
207
|
```ts
|
|
201
|
-
|
|
202
|
-
|
|
208
|
+
// models/registry.ts
|
|
203
209
|
export const MODEL_RUNTIME_NAMES = [/* … */, 'myprovider'] as const
|
|
204
210
|
|
|
211
|
+
// agents/model-runtime.ts
|
|
212
|
+
import { MyProviderAgent } from './myprovider-agent.js'
|
|
213
|
+
|
|
205
214
|
case 'myprovider':
|
|
206
215
|
return new MyProviderAgent({
|
|
207
216
|
modelId: cfg.modelId,
|
|
@@ -246,7 +255,7 @@ If a user wants to keep the key in an env var, they leave the `default` (`<<MY_P
|
|
|
246
255
|
|
|
247
256
|
### 5. Bump template version
|
|
248
257
|
|
|
249
|
-
In [packages/server/src/init.ts](../../../packages/server/src/init.ts), increment `TEMPLATE_VERSION` so new manifests and settings deploy on the next server start or `halo cli` / `halo tui` run (both call `refreshTemplatesIfOutdated`).
|
|
258
|
+
In [packages/server/src/init.ts](../../../packages/server/src/init.ts), increment `TEMPLATE_VERSION` so new manifests and settings deploy on the next server start or `halo cli` / `halo tui` run (both call `refreshTemplatesIfOutdated`). Editing an existing bundled yaml also bumps its `revision` (step 2).
|
|
250
259
|
|
|
251
260
|
### 6. Use it
|
|
252
261
|
|
|
@@ -331,9 +340,9 @@ If you POST `cache_control: { ttl: 'banana' }` and the server doesn't 400, that'
|
|
|
331
340
|
|
|
332
341
|
(Example: MiniMax docs say 5min, no `1h` mention → manifest only exposes `5m`. Aliyun's `cache_creation` field name is `ephemeral_5m_input_tokens`, also confirming 5min default. Don't put `1h` in the form just because `ttl: '1h'` doesn't 400.)
|
|
333
342
|
|
|
334
|
-
### Manifest changes need a server restart
|
|
343
|
+
### Manifest changes in `models/` need a server restart
|
|
335
344
|
|
|
336
|
-
The models registry is
|
|
345
|
+
The models registry is `{global}/models/*.yaml` merged with `{global}/models.d/*.yaml`, cached in the process (see [config.ts:loadModelsRegistry](../../../packages/server/src/config.ts)). Only `models.d/` is watched ([models/watcher.ts](../../../packages/server/src/models/watcher.ts) drops the cache and broadcasts `models:changed`). Editing or copying a new `models/<provider>.yaml` while the server is running has **no effect** — the form will keep showing the old presets until the next restart. To try a change live, put it in `models.d/` with a higher `revision` (or `halo models install <dir>`).
|
|
337
346
|
|
|
338
347
|
The cache is built **lazily on first access**, not at module-init. This is load-bearing for the seed flow: `ensureHaloHome` (which writes new provider manifests into `{global}/models/`) runs inside `main()`, but ES module imports — including `config.ts` — evaluate *first*. An eager `const registry = loadModelsRegistry()` at import time would read the dir before a freshly-added manifest lands, so a brand-new provider would be missing from the dropdown until a *second* restart. Lazy loading defers the read to a request / session-build path, which is always after `ensureHaloHome`. Don't switch it back to eager. (Fixed 2026-06-15; surfaced when a new provider needed two restarts to appear.)
|
|
339
348
|
|
|
@@ -414,6 +423,6 @@ The switch is keyed by runtime name (one case per wire protocol / vendor class),
|
|
|
414
423
|
- OpenAI-family format helpers: [packages/server/src/agents/openai-chat-format.ts](../../../packages/server/src/agents/openai-chat-format.ts)
|
|
415
424
|
- Anthropic-family body helpers: [packages/server/src/agents/anthropic-request.ts](../../../packages/server/src/agents/anthropic-request.ts)
|
|
416
425
|
- Manifest examples: [packages/server/templates/models/](../../../packages/server/templates/models/)
|
|
417
|
-
- Models registry loader: [packages/server/src/config.ts](../../../packages/server/src/config.ts) (`getModelsRegistry`)
|
|
426
|
+
- Models registry loader: [packages/server/src/config.ts](../../../packages/server/src/config.ts) (`getModelsRegistry`), merge rule in [packages/server/src/models/registry.ts](../../../packages/server/src/models/registry.ts), hub installer in [models/install.ts](../../../packages/server/src/models/install.ts), live reload in [models/watcher.ts](../../../packages/server/src/models/watcher.ts)
|
|
418
427
|
- Architecture context: [design/architecture.md#modelruntime](../design/architecture.md#modelruntime--llm-interaction-layer-provider-agnostic)
|
|
419
428
|
- Agent lifecycle context: [design/agent.md#agent-instance](../design/agent.md#agent-instance)
|
package/bundled-docs/dev/api.md
CHANGED
|
@@ -35,9 +35,9 @@ File: `packages/server/src/routes/files.ts`
|
|
|
35
35
|
| GET | `/api/files/search?projectId=&q=[&limit=][&dirsOnly=1]` | Recursive name search. Default limit 200, max 1000. Scans up to 50000 entries then truncates; returns `{matches, truncated}`. `dirsOnly=1` matches directories instead of files (powers the chat `@scope` directory completion). |
|
|
36
36
|
| GET | `/api/files?path=&projectId=` | Read file content (max 10 MB) |
|
|
37
37
|
| GET | `/api/files/stat?path=&projectId=` | Lightweight mtime + size |
|
|
38
|
-
| GET | `/api/files/download?path=&projectId=&inline=` | Download or inline-preview — streams the file (no full read into memory), supports `Range` (206 Partial Content) so `<video>`/`<audio>` can seek + partial-load; aborts the read if the client disconnects |
|
|
38
|
+
| GET | `/api/files/download?path=&projectId=&inline=` | Download or inline-preview — streams the file (no full read into memory), supports `Range` (206 Partial Content) so `<video>`/`<audio>` can seek + partial-load; aborts the read if the client disconnects. Missing file → 404 `{error:'File not found'}` |
|
|
39
39
|
| PUT | `/api/files` | Save file (body: `{path, content, projectId}`) |
|
|
40
|
-
| PUT | `/api/files/raw?path=&projectId=[&expectMtime=]` | Replace an **existing** file's bytes with the raw request body (binary-safe; `PUT /api/files` is utf-8 text only). Used by canvas preview extensions' save path. 404 if the file doesn't exist (save never creates)
|
|
40
|
+
| PUT | `/api/files/raw?path=&projectId=[&expectMtime=][&create=1][&append=1][&root=]` | Replace an **existing** file's bytes with the raw request body (binary-safe; `PUT /api/files` is utf-8 text only). Used by canvas preview extensions' save path. 404 if the file doesn't exist (save never creates) unless `create=1`; 400 for a directory. `expectMtime` is the `modifiedAt` the client loaded from `/api/files/stat` (float ms — compared after `Math.round`): when the on-disk mtime differs → **409** `{error:'conflict', mtime, size}` so the client can offer overwrite (retry without / with the new `expectMtime`). `create=1` creates a missing file (parent dirs `mkdir -p`); `append=1` appends instead of overwriting (`expectMtime` ignored; with `create=1` a missing file is created first) — the bundle extensions' `fs` write / append. `root=<dir>` (workspace-relative; the bundle host always sends its bundle path): `<dir>` must be an existing directory and `path` strictly inside it, else **404** `{error:'Bundle root not found'}` with nothing created. Success → `{ok, path, mtime, size}` |
|
|
41
41
|
| POST | `/api/files/new` | Create empty file |
|
|
42
42
|
| POST | `/api/files/mkdir` | Create directory |
|
|
43
43
|
| POST | `/api/files/rename` | Rename / move |
|
|
@@ -92,6 +92,7 @@ Installed extensions are directories under `~/.halo/global/extensions/<id>/` wit
|
|
|
92
92
|
| POST | `/api/extensions/install` | cookie | Multipart `file` = zip (≤ 100 MB, 413 above). Unpacks to a temp dir, validates the manifest (`id` must equal the directory / zip root, `entry` must exist), atomically replaces `~/.halo/global/extensions/<id>/`. Returns the resulting `ExtensionInfo`; 400 `{error}` for a bad zip / manifest |
|
|
93
93
|
| DELETE | `/api/extensions/:id` | cookie | Remove the directory. 400 for an invalid id shape, 404 if not installed. Returns `{ok, id}` |
|
|
94
94
|
| 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) |
|
|
95
|
+
| 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) |
|
|
95
96
|
|
|
96
97
|
**Why the asset route isn't cookie-authed.** The admin first hosted extensions in `<iframe sandbox="allow-scripts">` without `allow-same-origin` — an opaque origin. The document navigation still carries the cookie, but every subresource it loads (classic/module scripts, img, css, fetch, wasm, dynamic import, Worker) is a cross-site request with **no cookie**, and module/fetch/wasm need `Access-Control-Allow-Origin` too. A `?token=` query would be dropped when the document resolves `./viewer.js`-style relative URLs, so the token is a **path segment** that every relative URL inherits. The host now grants `allow-same-origin` (a cookie-auth proxy in front of halo — CloudFront + midway, oauth2-proxy — needs its *own* cookie on those requests, which only a same-origin iframe sends; see [design/canvas-extensions.md](../design/canvas-extensions.md#asset-serving-and-the-scoped-token)), so the admin cookie does travel again, but the route keeps the path token as its single auth path. `authMiddleware` lets exactly that path shape (`EXTENSION_ASSET_PATH = /^\/api\/extensions\/[^/]+\/[^/]+\/[^/]+\/./`) through without a cookie and the route verifies the token itself. The token is good for nothing else: `validateToken` (admin cookie / WS upgrade) refuses any JWT payload that carries a `scope`, so an asset token replayed as `halo_token` is a 401.
|
|
97
98
|
|
|
@@ -203,9 +204,9 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
|
|
|
203
204
|
|
|
204
205
|
| Method | Path | Purpose |
|
|
205
206
|
|---|---|---|
|
|
206
|
-
| 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),
|
|
207
|
-
| 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
|
|
208
|
-
| PUT | `/api/cron/jobs/:id` | Partial update over the same fields. Re-validates `schedule` / `runAt` and enforces trigger-mode exclusivity on the **merged** row: both in one body → 400 `schedule and runAt are mutually exclusive`; supplying one side is a mode switch that implicitly clears the other; a body that clears the active side without supplying a replacement → 400 `schedule or runAt required`. `runAt` must be a finite future epoch-ms number or `null`. `timeoutSec` follows the same partial-body contract: omitted = unchanged, `null` = clear back to the default, a number is range-validated (60–21600) same as create. `sessionId` likewise: omitted = unchanged, `null` / `''` = back to `cron
|
|
207
|
+
| 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), and `activeFrom` / `activeUntil` (`null` = unbounded) |
|
|
208
|
+
| 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 agent and access level (`agentId` only applies when the cli creates it); see [design/cron.md](../design/cron.md#execution-runjob) for the shared-session guards. `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. Returns `{ok, id}` |
|
|
209
|
+
| 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}` |
|
|
209
210
|
| DELETE | `/api/cron/jobs/:id` | Unschedule, delete every `cron_runs` row for the job, then delete the job. Returns `{ok}` |
|
|
210
211
|
| POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget). Returns `{ok}` |
|
|
211
212
|
| POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
|
|
@@ -415,7 +416,7 @@ File: `packages/server/src/routes/evolution.ts`. Surfaces the global `evolution_
|
|
|
415
416
|
| Method | Path | Purpose |
|
|
416
417
|
|---|---|---|
|
|
417
418
|
| GET | `/api/evolution/runs?archived=0\|1&limit=20&before=<createdAt>` | List evolution runs across workspaces, newest first, cursor-paginated (`limit` 1–300, `before` = previous page's `nextCursor`; `archived=1` lists archived runs instead). Returns `{runs, hasMore, nextCursor}`; each row carries the latest `applyId`/`applyStatus`. (Score isn't surfaced in the list — it's read from `score.json` on the detail fetch only.) |
|
|
418
|
-
| GET | `/api/evolution/runs/:id` | Detail: db row + `patch.md` + `score.json` + `.skip.md` (when `status='skipped'`) + wrapper/sub-cli logs + a snapshot summary (first user message + first assistant reply + message count). Row carries `failureReason` (and `applyFailureReason` from the latest apply) — apply-side values include `phase A': apply cli exited <code>`, `phase A': apply agent aborted: <first line of ABORT.md>`, and `phase A': apply agent didn't produce apply.log`. |
|
|
419
|
+
| GET | `/api/evolution/runs/:id` | Detail: db row + `patch.md` + `score.json` (parsed as-is — includes the scorer's `checks` object when present, see [design/evolution.md](../design/evolution.md#phase-c--score)) + `.skip.md` (when `status='skipped'`) + wrapper/sub-cli logs + a snapshot summary (first user message + first assistant reply + message count). Row carries `failureReason` (and `applyFailureReason` from the latest apply) — apply-side values include `phase A': apply cli exited <code>`, `phase A': apply agent aborted: <first line of ABORT.md>`, and `phase A': apply agent didn't produce apply.log`. |
|
|
419
420
|
| POST | `/api/evolution/runs/:id/approve` `{reviewerHint?}` | Move run from `awaiting_review` → `approved`, insert a pending `evolution_applies` row that the ticker will pick up. Returns `{ok, applyId}`; 409 when the run isn't `awaiting_review`. |
|
|
420
421
|
| POST | `/api/evolution/runs/:id/reject` | Move run from `awaiting_review` → `rejected`; 409 otherwise. |
|
|
421
422
|
| POST | `/api/evolution/runs/:id/retry` `{hint}` | Reset a finished run back to `pending` (attempts cleared) with the supplied hint (required, else 400) so the wrapper picks it up again. Rejected with 409 when the run is already `running` / `pending`. |
|