@turmind/halo 1.5.8 → 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/canvas-extensions.md +48 -10
- package/bundled-docs/design/cron.md +6 -6
- package/bundled-docs/design/evolution.md +47 -15
- package/bundled-docs/design/express-self.md +86 -19
- package/bundled-docs/design/storage.md +1 -0
- package/bundled-docs/dev/api.md +7 -6
- package/bundled-docs/dev/desktop-packaging.md +26 -0
- package/bundled-docs/guide/agents.md +1 -1
- package/bundled-docs/guide/chat.md +3 -0
- package/bundled-docs/guide/workspace.md +2 -2
- package/bundled-docs/requirements/agent.md +1 -1
- package/bundled-docs/requirements/chat.md +15 -7
- 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 +1798 -1325
- 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 +469 -75
- package/templates/skills/cron/SKILL.md +59 -4
- package/templates/skills/cron/manage-cron.py +158 -24
- package/templates/skills/self/SKILL.md +59 -16
- 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-df2401d289d09a7e.js +0 -1
- package/admin-out/_next/static/chunks/app/page-ed2976abf85a335d.js +0 -1
- package/admin-out/_next/static/css/b8c87b919758c52b.css +0 -3
- /package/admin-out/_next/static/{T3x-q3QCOgSpfydoH53XD → RD5w3FmDZ6HtyQGGaUb8t}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{T3x-q3QCOgSpfydoH53XD → 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,24 @@ 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, show, hold, pulse, flash, shake, rest, gap}, ...]` (`show` = an image, see [Image](#image-show--a-picture-gathered-out-of-the-dots)). Each beat waits for the engine's internal clock. Enqueued; calling play() again appends to the queue rather than cancelling it.
|
|
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 (and cancel the running scene's remaining beats); also stops a playing voice clip and
|
|
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`.
|
|
81
86
|
|
|
82
87
|
### Image (`show` — a picture gathered out of the dots)
|
|
83
88
|
|
|
@@ -86,7 +91,69 @@ Provided by `self.html` as `const self = {…}`. All expression methods are sand
|
|
|
86
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.
|
|
87
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.
|
|
88
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).
|
|
89
|
-
- One picture at a time; `QUEUE_MAX` and the drop-oldest rule apply as for any scene.
|
|
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.
|
|
90
157
|
|
|
91
158
|
### Reactions (named vocabulary)
|
|
92
159
|
|
|
@@ -98,11 +165,11 @@ Provided by `self.html` as `const self = {…}`. All expression methods are sand
|
|
|
98
165
|
|
|
99
166
|
## Key files
|
|
100
167
|
|
|
101
|
-
- **Engine template:** `packages/server/templates/canvas/self.html` — particle field, mode switching, API surface, voice audio graph, image overlay (`show`). 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.
|
|
102
169
|
- **Skill instruction:** `packages/server/templates/skills/self/SKILL.md` — teaches the agent when/how to use the face.
|
|
103
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.
|
|
104
|
-
- **Iframe registration:** `packages/admin/src/features/editor/face-bridge.ts` — module-level registry of mounted previews; `postToFace()` forwards payloads via `postMessage
|
|
105
|
-
- **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).
|
|
106
173
|
- **Marker stripping:** `packages/admin/src/shared/components/message-list.tsx:TextBlock()` — strips both `<<<CAPTURE>>>` and `<<<SHOW:...>>>` before rendering.
|
|
107
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.
|
|
108
175
|
|
|
@@ -111,7 +178,7 @@ Provided by `self.html` as `const self = {…}`. All expression methods are sand
|
|
|
111
178
|
The face is a fixed grid of particles. Each knows its current position and a target position, easing between them every frame.
|
|
112
179
|
|
|
113
180
|
- **Grid:** 22px spacing, 60fps animation loop
|
|
114
|
-
- **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.
|
|
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()`).
|
|
115
182
|
- **Glyph sampling:** Text→offscreen canvas→pixel alpha sampling→nearest-particle assignment (greedy scan with shuffle for repeated words)
|
|
116
183
|
- **Emoji accent:** Maps common emoji to ASCII (`👍`→`+1`, `❤`→`<3`, etc.) so the monospace aesthetic stays consistent; anything untranslated is stripped
|
|
117
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
|
|
@@ -125,10 +192,10 @@ The marker is a **verbatim pipe** — Halo never parses or validates the payload
|
|
|
125
192
|
|
|
126
193
|
- **Format:** `<<<SHOW: <js> >>>` where `<js>` is a complete JavaScript expression or statement
|
|
127
194
|
- **Scope:** The code runs in a function closure with `self` as the API surface: `(new Function('self', code))(self)`
|
|
128
|
-
- **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.
|
|
129
196
|
- **Order:** Multiple markers in one reply are extracted in sequence and forwarded in order; scene beats queue and play sequentially
|
|
130
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
|
|
131
|
-
- **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)).
|
|
132
199
|
|
|
133
200
|
### Trust model — why `new Function` is acceptable here
|
|
134
201
|
|
|
@@ -164,6 +231,6 @@ From `self/SKILL.md`:
|
|
|
164
231
|
|
|
165
232
|
## Scope and out-of-scope
|
|
166
233
|
|
|
167
|
-
Supported: all `self` API calls (say/play/intro/react/pulse/flash/shake/voice/show/rest); 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`).
|
|
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`).
|
|
168
235
|
|
|
169
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.
|
|
@@ -370,6 +370,7 @@ Path conventions:
|
|
|
370
370
|
- `<provider-id>.secrets.<key>` — server-side credentials for a model provider
|
|
371
371
|
- `<skill-id>.params.<key>` — values an agent can reference via `{{<skill-id>.params.<key>}}`
|
|
372
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)
|
|
373
374
|
|
|
374
375
|
### Load precedence
|
|
375
376
|
|
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`. |
|
|
@@ -405,6 +405,32 @@ Still good practice: build server + admin **before** `pnpm dist:arm64`.
|
|
|
405
405
|
confirm does nothing, because `if (!confirm(...)) return` always returns). In
|
|
406
406
|
a plain browser the helpers fall back to the native sync dialogs.
|
|
407
407
|
|
|
408
|
+
- **Media capture for extensions (`media` capability, e.g. htrans) —
|
|
409
|
+
⚠ not yet verified on a real Mac.** Three pieces, all needed:
|
|
410
|
+
- **Info.plist usage strings** (`electron-builder.yml` → `mac.extendInfo`):
|
|
411
|
+
`NSCameraUsageDescription`, `NSMicrophoneUsageDescription`, and
|
|
412
|
+
`NSAudioCaptureUsageDescription` (system-audio capture; required on macOS
|
|
413
|
+
14.2+). Missing ones make macOS deny / kill the request silently.
|
|
414
|
+
- **`--enable-features=MacLoopbackAudioForScreenShare,MacSckSystemAudioLoopbackOverride`**
|
|
415
|
+
on macOS (`main.cjs`, appended before app `ready`, merged with any existing
|
|
416
|
+
`enable-features` value) — Chromium's loopback (system-audio) capture is
|
|
417
|
+
behind these flags; macOS 13+ (Darwin 22+) only, so older macOS never
|
|
418
|
+
offers or passes `audio:'loopback'`.
|
|
419
|
+
- **`getDisplayMedia()` picker**: Electron has no built-in one (the call just
|
|
420
|
+
fails), so `session.setDisplayMediaRequestHandler` (mac / win only; Linux
|
|
421
|
+
keeps Electron's default) lists `desktopCapturer` sources and asks the
|
|
422
|
+
requesting window's preload to render a picker (IPC `halo:display-pick` /
|
|
423
|
+
`halo:display-picked` / `halo:display-pick-cancel`; resolved against the
|
|
424
|
+
top frame, since preload doesn't run in the extension iframe). One answer
|
|
425
|
+
per request: cancel / 120 s timeout / page gone → `callback(null)` (the
|
|
426
|
+
page's promise rejects) — never `callback({})`, which throws when video was
|
|
427
|
+
requested. Audio = `'loopback'` only when requested AND supported (win, or
|
|
428
|
+
macOS 13+).
|
|
429
|
+
- **Unload confirm**: `will-prevent-unload` shows a native Leave / Stay box
|
|
430
|
+
(zh / en by app locale); the admin only blocks the unload while a tab is
|
|
431
|
+
busy (recording) or unsaved, so it doesn't fire on an ordinary close or a
|
|
432
|
+
workspace switch.
|
|
433
|
+
|
|
408
434
|
- **Cross-staging needs a full restage — auto-fast skips the native fixup.**
|
|
409
435
|
After editing `stage-runtime.mjs`, or any time you cross-stage a target whose
|
|
410
436
|
`node_modules` differs from what's on disk, run with `HALO_STAGE_FULL=1`. The
|
|
@@ -113,7 +113,7 @@ Top-right `Test` button:
|
|
|
113
113
|
|
|
114
114
|
Better than the old built-in test chat — it has full workspace tools + session persistence.
|
|
115
115
|
|
|
116
|
-
Internal agents (`internal: true`, e.g. self-evolution agents) have no Test button and never appear in the chat agent selector or `/session new`'s default pick — they're delegated to by other agents, never driven directly. They remain editable in the management sidebar's collapsed **Internal** group.
|
|
116
|
+
Internal agents (`internal: true`, e.g. self-evolution agents) have no Test button and never appear in the chat agent selector, the Cron agent picker or `/session new`'s default pick — they're delegated to by other agents, never driven directly. They remain editable in the management sidebar's collapsed **Internal** group. The goal-mode judge (`goal`) isn't listed there either: `/goal` starts it, and there's nothing to configure for it in the UI.
|
|
117
117
|
|
|
118
118
|
## Delete
|
|
119
119
|
|
|
@@ -34,6 +34,9 @@ The input toolbar shows a context chip (the open file's name, or the selection)
|
|
|
34
34
|
|
|
35
35
|
Click the chip (it turns struck-through) if you don't want that context injected.
|
|
36
36
|
|
|
37
|
+
Independent of the chip, while the ✨ **face** toggle in the toolbar is on (the agent's live face, `.halo/canvas/self.html`, pinned as an editor tab) every message also carries one line:
|
|
38
|
+
- `[Face open: .halo/canvas/self.html · last: …]` — tells the agent the face is on screen; the optional `last:` part is a short receipt of what its previous face actions did (e.g. `show a.png fail`, `voice blocked (needs a click)`). Receipts never wake the agent on their own; they ride along with your next message. The line is not shown in your bubble and is stripped from session titles.
|
|
39
|
+
|
|
37
40
|
## `@` file mention
|
|
38
41
|
|
|
39
42
|
Typing `@` in the input opens a file search:
|
|
@@ -28,10 +28,10 @@ The docked Bottom Panel belongs to the Explorer tab; every other tab fills the w
|
|
|
28
28
|
| Icon | Tab | Purpose |
|
|
29
29
|
|---|---|---|
|
|
30
30
|
| 📄 Files | Explorer | File tree + Monaco editor |
|
|
31
|
-
| 🌿 Branch | Source Control | Changes, commit, push, history (hidden for non-git workspaces) |
|
|
32
31
|
| 📨 Messages | Sessions | Session history + debug viewer |
|
|
33
|
-
|
|
|
32
|
+
| 🌿 Branch | Source Control | Changes, commit, push, history (hidden for non-git workspaces) |
|
|
34
33
|
| 🤖 Bot | Agents | Agent configuration (Form / Edit) |
|
|
34
|
+
| ⚡ Zap | Skills | Skill editing (mini workspace) |
|
|
35
35
|
| 💬 Chat bubble | Channels | Web / Telegram / Slack / Feishu / WeCom / WeChat accounts |
|
|
36
36
|
| ✨ Sparkles | Evolution | Self-evolution runs and review |
|
|
37
37
|
| 🕐 Clock | Cron | Scheduled agent runs |
|
|
@@ -88,6 +88,6 @@ API: `GET/PUT /api/agent-configs/:id/md/:fileType`, `GET /api/agent-configs/:id/
|
|
|
88
88
|
- **Not rendered for internal agents** (`internal: true`) — they're delegated to by other agents, never driven directly
|
|
89
89
|
|
|
90
90
|
### Internal agents
|
|
91
|
-
Agents flagged `internal: true` in agent.yaml (e.g. `__evo_agent__`, `__apply_agent__`, `__score__`) are platform tooling. They are hidden from every user-facing surface: the delegation roster, `/session new` default pick,
|
|
91
|
+
Agents flagged `internal: true` in agent.yaml (e.g. `__evo_agent__`, `__apply_agent__`, `__score__`, `goal`) are platform tooling. They are hidden from every user-facing surface: the delegation roster, `/session new` default pick, the chat agent selector, the Cron job agent picker and `halo cli`'s agent list (`halo agents`) — and have no Test button. They stay editable in the management sidebar's collapsed **Internal** group — except the goal-mode judge (`goal`), which is only ever started by `/goal` and isn't listed on the Agents page at all (edit its `agent.yaml` on disk if needed).
|
|
92
92
|
|
|
93
93
|
Replaces the old built-in test chat panel, giving a more realistic environment (full workspace tools + session persistence).
|