@turmind/halo 2.1.6 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/1607-bf227fc5e4db45b7.js +1 -0
- package/admin-out/_next/static/chunks/1989-3b2faa2731d973c4.js +1 -0
- package/admin-out/_next/static/chunks/5287.7ea08918388fa804.js +1 -0
- package/admin-out/_next/static/chunks/app/page-10a4afac9cbc27a2.js +1 -0
- package/admin-out/_next/static/chunks/{webpack-7f8156dbbdb54868.js → webpack-9532ec7f1fa9a757.js} +1 -1
- package/admin-out/_next/static/css/8fc43fce83e5ce5d.css +1 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/a2a.md +3 -3
- package/bundled-docs/design/agentcore.md +1 -1
- package/bundled-docs/design/architecture.md +3 -3
- package/bundled-docs/design/background-dispatch.md +2 -2
- package/bundled-docs/design/canvas-extensions.md +2 -2
- package/bundled-docs/design/cron.md +77 -53
- package/bundled-docs/design/evolution.md +111 -39
- package/bundled-docs/design/node.md +5 -12
- package/bundled-docs/design/observability.md +11 -11
- package/bundled-docs/design/prompt-system.md +1 -1
- package/bundled-docs/design/relay.md +2 -2
- package/bundled-docs/design/session.md +11 -9
- package/bundled-docs/design/slack.md +1 -1
- package/bundled-docs/design/storage.md +14 -11
- package/bundled-docs/design/telegram.md +1 -1
- package/bundled-docs/design/ws.md +9 -7
- package/bundled-docs/dev/api.md +17 -14
- package/bundled-docs/dev/env.md +2 -2
- package/bundled-docs/dev/logging.md +1 -1
- package/bundled-docs/dev/tools.md +2 -2
- package/bundled-docs/guide/agents.md +1 -1
- package/bundled-docs/guide/cli.md +3 -3
- package/bundled-docs/guide/delegation-and-access.md +1 -1
- package/bundled-docs/guide/workspace.md +1 -1
- package/bundled-docs/requirements/agent.md +1 -1
- package/bundled-docs/requirements/command.md +1 -1
- package/bundled-docs/requirements/settings.md +2 -2
- package/bundled-docs/requirements/workspace.md +4 -4
- package/dist/evo-wrapper.js +913 -201
- package/dist/index.js +2680 -1768
- package/package.json +1 -1
- package/templates/agents/__apply_agent__/AGENT.md +10 -6
- package/templates/agents/__evo_agent__/AGENT.md +15 -4
- package/templates/agents/__score__/AGENT.md +4 -0
- package/templates/skills/cron/SKILL.md +68 -16
- package/templates/skills/cron/manage-cron.py +91 -19
- package/admin-out/_next/static/chunks/1101-d2f818895a3aa32e.js +0 -1
- package/admin-out/_next/static/chunks/1989-c24411a5e971b8b9.js +0 -1
- package/admin-out/_next/static/chunks/5058.8514aa6d6a947cce.js +0 -1
- package/admin-out/_next/static/chunks/app/page-84ab8d21c70823c0.js +0 -1
- package/admin-out/_next/static/css/6da86a077e119a6c.css +0 -1
- /package/admin-out/_next/static/{xg-EvYXc8NEUDwTlmbWvy → j4QFRrUtg33DNMUIgg4ij}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{xg-EvYXc8NEUDwTlmbWvy → j4QFRrUtg33DNMUIgg4ij}/_ssgManifest.js +0 -0
|
@@ -22,7 +22,7 @@ Their metadata row lives in a global db next to the files, `~/.halo/global/inter
|
|
|
22
22
|
|
|
23
23
|
No file holds metadata. Everything the 1.x header carried is derived or in the session's db row: agentId = directory, leaf id = file name, archive count = segment files on disk; `title`, `exchange_count`, `context_tokens`, `total_output_tokens`, `output`, `last_activity_at` = row columns (see [SQLite metadata](#sqlite-metadata)).
|
|
24
24
|
|
|
25
|
-
**Writes append.** `sessions/session-log.ts` keeps a process-local writer cache per file (what this process last wrote or read, stamped with the file's ino / size / mtime). A save diffs memory against it **by object identity** — a persisted row or raw message is never edited in place; every mutator replaces the object (raw: or its `content` array, or changes its length) — and appends only the new records with one `write` on an `O_APPEND` fd. A full atomic rewrite (tmp + rename) happens only for real non-append changes: compaction (raw), archive / `replaceMessageLog` / `deleteExchange`, a stamp that no longer matches (another process — a
|
|
25
|
+
**Writes append.** `sessions/session-log.ts` keeps a process-local writer cache per file (what this process last wrote or read, stamped with the file's ino / size / mtime). A save diffs memory against it **by object identity** — a persisted row or raw message is never edited in place; every mutator replaces the object (raw: or its `content` array, or changes its length) — and appends only the new records with one `write` on an `O_APPEND` fd. A full atomic rewrite (tmp + rename) happens only for real non-append changes: compaction (raw), archive / `replaceMessageLog` / `deleteExchange`, a stamp that no longer matches (another process — the CLI / TUI, a manual `halo cli -s` — wrote the file: rewrite from this process's memory, the same last-writer-wins as 1.x), or the first write of a file in this process. The stamp is checked at the write itself, not by a stat before every save: an append or tail write compares it with the `fstat` of the fd it writes through and turns into a full rewrite on a mismatch; only a save that is a full rewrite anyway stats first (its exchange-count delta is relative to what the file holds); a save with nothing new touches nothing. **Another process's write** (the TUI, a manual `halo cli -s`, a DEV server on a shared workspace) is read, not overwritten, before this process uses its cached copies again. The root's two copies — the UIState (kept past release) and an idle AgentSession a view loaded — can part wherever one of them is read from disk while the other is still cached, so the check sits at those points, one stat per cached copy against its writer baseline: `appendUserMessage` (a turn's user row, checked before it lands — dropping the state after would discard it; this precedes the `ensureSession` check on the WS path), `ensureSession`'s restore (raw about to be read; the cached UIState is checked), `ensureUIState`'s disk seed (the UI about to be read; a cached idle root AgentSession whose raw changed reloads its history in place — `refreshStaleHistory`, in place because callers such as `querySession` hold the object and its queue), plus `deleteExchange` (reads UI before raw), manual `compactSession` (its UI seed runs after `beginCompact`, past the seed's guard) and `querySession` waking an idle root with both copies cached (no point above runs: `ensureSession` returns the cached session, the emit doesn't seed) — only when the session was already in memory before the await (a cold wake's restore has just checked), and there only the UIState is dropped, after `ensureSession`'s await; the emit re-seeds it and `refreshStaleHistory` reloads the history in place. Never the AgentSession: a sub's auto-report calls `querySession` from its `finally`, before its release, and dropping the tree there would skip that release's flush and save. A file with no baseline — the load found a torn line or another process's open in-flight row — counts as written elsewhere unless the UIState holds unsaved rows. On a hit the tree's idle copies are dropped (`forgetExternalWrite`) and both files reload from disk; the dropped UIState's pending debounced writes are cancelled, not flushed, when the UI log itself was written elsewhere (a flush would rewrite the other writer's file from the stale state) — a `system` / `user` row from the last ≤500ms before the other writer wrote is the cost; after a raw-only write elsewhere the UI file is still this process's, and they flush as usual. The view path (`prepareForView` re-seeding a root this process doesn't drive) cancels the same way when the file was written elsewhere, and flushes otherwise. Skipped while the tree has a turn or compact in flight or a queued message — live state isn't dropped, and the two writers race (the last full rewrite wins). So a normal turn after a release stats the UI log twice (`appendUserMessage`, then the restore). Known gap: another process starting a turn in the few ms between `appendUserMessage` and `ensureSession`'s restore (the `buildAgentInstance` await) makes the restore's check drop the UIState — this process's user row, already in it, is then missing from the UI log, though the turn and its raw copy run. Not when the other process is mid-turn: the seed taken over its open in-flight row has no baseline, and a state with unsaved rows and no baseline isn't dropped. Whenever a save does replace another process's write (a copy still cached with no check in between: a notification, a Stop fold), the session's other log is rewritten whole from memory in the same save (`writeUILog` → `foreign`, `writeRawLog` → true; `persistUILog` / `saveAgentState` pair them), so the two files stay a pair, never one with the other process's turns beside this process's. One exception: a sub-session's raw write after its `agent_done` rewrites raw only — its UI log left the root's UIState at `agent_done`, so there is no copy to pair it with. Never spliced into the other's file either way. Loaders that hand their result to the live session (`prime`) seed the cache, so the first save after a restore appends. Writes stay synchronous on the same call sites as 1.x, so UI and raw read→diff→write never interleave, the tombstone check and the write share one synchronous slice, and `flushAll` lands before exit. A torn last line (crash mid-write, or an EFS unmount) fails to parse: the reader drops it and the next write is a full rewrite.
|
|
26
26
|
|
|
27
27
|
**The in-flight assistant has a stable id.** `TurnState.inflight = { id, at }` is assigned by the first snapshot of a streaming reply and reused by `flushAssistantMessage` / `flushCompletedAssistantMessage` for the finalized row. Every persist mid-stream writes an `inflight` record carrying only the changed blocks; the final `add` with the same id retires it — exactly one row, and live views (WS / web snapshots) show the same id. An interrupt flush writes the partial reply under that id, raw gets the streamed text + `INTERRUPTED_REPLY_MARKER`, so after a reload both halves hold the partial reply once. SIGKILL loses what streamed after the last write.
|
|
28
28
|
|
|
@@ -31,12 +31,12 @@ No file holds metadata. Everything the 1.x header carried is derived or in the s
|
|
|
31
31
|
- **Save points.** `tool_call` (the tool_use turn), each `tool_result` (it lands in the history as it finishes — `agent-loop` no longer holds a batch's results until the batch ends), `usage` (the finished call, including the final reply without tool calls), and a successful compaction (LLM summary, its local fallback, the context-overflow local compact — saved at once, so a hard kill can't restore the pre-compact raw under a UI log already showing the summary). The UI flushes on the same events instead of debouncing. A change confined to the raw log's last message (a result landing in the batch's results message) rewrites that one line in place — see [storage.md](storage.md#session-files-20).
|
|
32
32
|
- **Retry.** A failed call that streamed text / thinking and is about to be retried emits `call_discarded` (before the retry notice): the UI drops that call's blocks from the turn by `turnId`, the admin gets `chat:discard` ([ws.md](ws.md)). The model never saw them either — the retry re-streams from scratch.
|
|
33
33
|
- **Abandoned call** (account error, unrecoverable error after the retries): the UI keeps the partial; raw gets it too, suffixed with `ERROR_REPLY_MARKER`, saved at once. Only the last attempt's partial — earlier attempts were discarded on retry.
|
|
34
|
-
- **Server shutdown** (`landForShutdown`, called at shutdown and once more right before exit): flushes the unpersisted UI logs, then writes the raw log of every session this process is **running** (`promise !== null`, subs too), a half-streamed reply as a **copy** + `SHUTDOWN_REPLY_MARKER` (the live history is left alone) and the row's `output` / `last_activity_at` (`saveRow` — a throttled trailing write would die with the process). An idle session in memory (loaded by `/context`, a manual `/compact`, a `query_session` target) is never written: every change to its history was saved on the spot, and its copy may be stale — a
|
|
34
|
+
- **Server shutdown** (`landForShutdown`, called at shutdown and once more right before exit): flushes the unpersisted UI logs, then writes the raw log of every session this process is **running** (`promise !== null`, subs too), a half-streamed reply as a **copy** + `SHUTDOWN_REPLY_MARKER` (the live history is left alone) and the row's `output` / `last_activity_at` (`saveRow` — a throttled trailing write would die with the process). An idle session in memory (loaded by `/context`, a manual `/compact`, a `query_session` target) is never written: every change to its history was saved on the spot, and its copy may be stale — a manual `halo cli -s` run or the TUI may have written the same session since, and a write from memory would replace those turns (stamp mismatch → full rewrite). The streamed-text buffer a copy is made from is cleared the moment the call's assistant turn lands in the history (`run()`'s `onAssistantLanded`), so neither the shutdown copy nor the error landing ever duplicates a reply already there. It ends nothing — no abort, no repair, no release, the run ledger untouched; the next boot nudges the runs and repairs their unanswered tool_use on load. What it lands is final, because nothing new starts once shutdown has begun (`beginShutdown`, see **Shutdown** under [Boot reconcile](#boot-reconcile-of-crash-orphans-reconcileorphansonboot)): no model call, no shell command. Idempotent. A session mid-compact keeps its raw log as is: its live history holds the summarize turn, and its raw on disk is the pre-compact state every result already reached. Its row is written all the same — row and raw don't depend on each other.
|
|
35
35
|
- **Hard kill.** What landed at the last save point stays; an unfinished tool call is repaired on the next load, a stream after the last write is lost.
|
|
36
36
|
|
|
37
37
|
**A failed 1.x conversion blocks the session.** Opening a session whose 1.x file can't be converted (bad file, read-back check failed, permission) throws `LegacyConversionError` (`<seg>: 1.x conversion failed: <reason> — run \`halo sessions migrate v1 v2\``) from every reader and writer of that session instead of opening it empty — an empty seed would show, and the next write persist, a truncated history. Every open retries (the full error is logged once per process). `ensureSession` refuses to restore it, so a send to it fails visibly on every entry point — REST / WS / CLI with an error, the web channel's SSE with an `error` frame after `session` ([web.md](web.md#a-failure-ends-the-stream-with-error)); per-row readers — the session list, `getSessionTitle`, a cold `get_session_output` — catch it for that row, so one broken session never breaks a list. The boot sweeps (run ledger, goal mode) skip it.
|
|
38
38
|
|
|
39
|
-
**Conversion is serialized across processes.** Every process that opens a session's files converts a 1.x session on first open (the server,
|
|
39
|
+
**Conversion is serialized across processes.** Every process that opens a session's files converts a 1.x session on first open (the server, the CLI / TUI, the migrate command), so one session's conversion runs under `<seg>.migrating` (O_EXCL, holding the converter's pid). A lock held by a live pid is waited on (up to 10s, synchronously, in 50ms steps), and the waiter goes on as soon as the conversion has landed (`ui.jsonl` present **and** the 1.x file gone — `ui.jsonl` alone appears before the holder's read-back check, and appending then would make that check fail); past the wait the open throws like a failed conversion. A lock held by a dead pid, by this process's own pid (a dead predecessor's, reused), or empty and older than the wait is taken over. The rollback after a failed check runs only while still holding the lock. Single-host only (not NFS); a pid reused by a live process reads as alive — the open then waits the full 10s and throws, and the next open retries.
|
|
40
40
|
|
|
41
41
|
- **title**: set once, then sticky — a UI-log write sets the row's `title` only while it is empty (`updateSessionMeta`'s `titleIfUnset`); a rename via `PATCH /sessions/logs/:id` overwrites it. Where it comes from depends on the session kind:
|
|
42
42
|
- **Root**: the first user message (editor-context prefixes stripped, 60 chars), or the explicit `title` passed to `createSession`, which lands in the row on insert.
|
|
@@ -57,7 +57,7 @@ Full format in [storage.md](storage.md#session-files-20).
|
|
|
57
57
|
- **`messages` (UI log): soft delete.** The target user message and every following message up to (not including) the next user message get `deleted: true`. The array length is unchanged, so `messageCount` semantics hold and the turn stays rendered — greyed out in the admin. There is no undo; a deleted exchange hides its own Delete button.
|
|
58
58
|
- **`rawMessages` (LLM-facing): physical delete.** The row's own block (located by its stamp, below), plus the reply through to the next user-turn start when the row owns it, so `tool_use` / `tool_result` pairs are never split (an orphaned `tool_result` would make every subsequent API call error out). `repairConversationMessages` then cleans the seam.
|
|
59
59
|
|
|
60
|
-
**Turn matching by stamp.** A main user row carries `stamp` — its [arrival stamp](#message-queue-and-drain), the same value its raw text block starts with (`[<stamp>] …`). `deleteRawBlock` finds the one raw user text block starting with it and removes it together with the image blocks / `[image removed: …]` placeholders right before it (a message's images precede its text). If a later user text block follows in the same raw message, that block is a later row (a queue batch, a Stop fold, `run()`'s merge into a dangling turn) and owns the reply — only this row's blocks go. Otherwise the reply goes too, through to the next turn start, and whatever the message keeps (tool results it answers — a soft interrupt lands the new input in the batch's results message — or earlier rows' blocks) merges into the next turn's opening, results leading. So in a merged message deleting an earlier row keeps the reply, deleting the last row leaves the earlier rows as a dangling user turn (a legal state: the next input merges into it), deleting all of them drops the turn. A stamp held by two UI rows, or by two raw blocks, is refused (`'raw_unmatched'`) — one process never repeats a value; only a restart after the wall clock stepped back, or two processes (a
|
|
60
|
+
**Turn matching by stamp.** A main user row carries `stamp` — its [arrival stamp](#message-queue-and-drain), the same value its raw text block starts with (`[<stamp>] …`). `deleteRawBlock` finds the one raw user text block starting with it and removes it together with the image blocks / `[image removed: …]` placeholders right before it (a message's images precede its text). If a later user text block follows in the same raw message, that block is a later row (a queue batch, a Stop fold, `run()`'s merge into a dangling turn) and owns the reply — only this row's blocks go. Otherwise the reply goes too, through to the next turn start, and whatever the message keeps (tool results it answers — a soft interrupt lands the new input in the batch's results message — or earlier rows' blocks) merges into the next turn's opening, results leading. So in a merged message deleting an earlier row keeps the reply, deleting the last row leaves the earlier rows as a dangling user turn (a legal state: the next input merges into it), deleting all of them drops the turn. A stamp held by two UI rows, or by two raw blocks, is refused (`'raw_unmatched'`) — one process never repeats a value; only a restart after the wall clock stepped back, or two processes (a manual `halo cli -s` run and the server) writing one session in the same millisecond, could.
|
|
61
61
|
|
|
62
62
|
**A stamped row raw doesn't hold is refused.** Every entry point passes the row's stamp on, so a stamped row with no block in raw never reached the model's history — a message queued while busy and lost to a restart before the drain, or a send that threw after the append. After the last summary that is `'raw_unmatched'`; before it, UI-only as above. It never falls back to the text match: with another turn of the same text in raw, the count would land on that turn and delete it silently.
|
|
63
63
|
|
|
@@ -71,7 +71,7 @@ Full format in [storage.md](storage.md#session-files-20).
|
|
|
71
71
|
|
|
72
72
|
**Stale anchors are refused (`'archived'`).** `userOrdinal` is positional over the log both sides can see, so it only means anything while client and server agree where the log *starts*. Archiving moves that start forward, and a chat panel open across the compact still counts from the pre-archive top — the same ordinal then maps onto a different turn on the server. The payload now carries the anchor: the client sends `archiveCount`, the archived-segment count its view was opened against. `deleteExchange` counts the committed segments on disk (`countArchiveSegments`, not memory — the segment files are the truth about what was archived) and refuses with `'archived'` only when the two differ; a session with archives can still be deleted from as long as the anchor matches. The WS layer surfaces the refusal as an error frame with `code: 'archived'` (see [ws.md](ws.md)); the admin renders the message verbatim, without the `Error:` prefix it adds to unexpected failures.
|
|
73
73
|
|
|
74
|
-
**Memory/disk sync.** A live session mutates `agent.messages` in place then `saveAgentState`; a cold session's logs are read, edited and rewritten whole (atomic, through the same writers; a 1.x session is migrated first by that read). Rejected with `running` / `compacting` while a turn is in flight (mutating raw mid-turn would corrupt the in-flight conversation). Refresh is push-based: the
|
|
74
|
+
**Memory/disk sync.** A live session mutates `agent.messages` in place then `saveAgentState`; a cold session's logs are read, edited and rewritten whole (atomic, through the same writers; a 1.x session is migrated first by that read). Rejected with `running` / `compacting` while a turn is in flight (mutating raw mid-turn would corrupt the in-flight conversation). Refresh is push-based: the connection subscribed to the session gets `exchange:deleted` (the deleted row ids + the turn's ordinal — not the whole log), other open sessions pick it up via the existing `.halo/sessions/` file watcher. Entry point: WS `exchange:delete` → `handler.ts:handleExchangeDelete` (see [ws.md](ws.md)).
|
|
75
75
|
|
|
76
76
|
## SessionManager
|
|
77
77
|
|
|
@@ -186,12 +186,12 @@ The constructor chain is now `reconcile orphans → sweepActiveGoals (retained l
|
|
|
186
186
|
|
|
187
187
|
The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the **root** the server restarted — asked "done yet?" it still answers "waiting on reports" forever. Plain roots had no restart nudge, because "which session is running right now" only ever lived in memory.
|
|
188
188
|
|
|
189
|
-
**Ledger** (`db/runs-db.ts`): a global `~/.halo/global/runs.db` (same singleton pattern as `evo.db` / `cron.db` — `setRunsDb`/`getRunsDb`, WAL), one table `running_sessions(workspace TEXT, session_id TEXT, started_at INTEGER, PK(workspace, session_id))`. `runSession` inserts the id on entry and deletes it in its `finally`, so the steady state is an **empty table** — whatever is still there at boot is exactly the set of runs the previous process died in the middle of; no pid, no liveness probe needed. Only the long-lived server writes: `SessionManager.ledgerEnabled = opts.reconcileOrphansOnBoot === true`, the same flag that gates the orphan reconcile — cli /
|
|
189
|
+
**Ledger** (`db/runs-db.ts`): a global `~/.halo/global/runs.db` (same singleton pattern as `evo.db` / `cron.db` — `setRunsDb`/`getRunsDb`, WAL), one table `running_sessions(workspace TEXT, session_id TEXT, started_at INTEGER, PK(workspace, session_id))`. `runSession` inserts the id on entry and deletes it in its `finally`, so the steady state is an **empty table** — whatever is still there at boot is exactly the set of runs the previous process died in the middle of; no pid, no liveness probe needed. Only the long-lived server writes: `SessionManager.ledgerEnabled = opts.reconcileOrphansOnBoot === true`, the same flag that gates the orphan reconcile — cli / TUI / evo-wrapper runs end with their terminal and never enter the table (cron runs in the server since 2.2, so its runs are recorded like any root's). Writes go through a private `ledgerWrite` wrapper that try/catches and only `console.warn('[RunLedger] ledger write failed …')` on failure: a sqlite hiccup (disk full, EIO) must not drop a message or strand a session at `promise = null` without a `complete` — worst case is one stale/missing row, i.e. one spurious or missed nudge at next boot.
|
|
190
190
|
|
|
191
191
|
**Sweep** (`sweepInterruptedRuns`, `agents/run-ledger.ts`): runs from the SessionManager constructor right after the retained legacy `sweepActiveGoals`, under the same ownership gate. `drainRunning(workspace)` reads and deletes that workspace's rows in one transaction **before** any nudge goes out — the invariant being that a nudge's own re-dispatch inserts a fresh row, which must never be mistaken for a leftover on some future sweep. Ids are grouped by root (`id.split('>')[0]`); each root gets one append-then-send nudge, worded by whether the root's **own** id is among the rows:
|
|
192
192
|
|
|
193
193
|
- **Root mid-run** (text starts `[System] The server restarted at <ISO> while you were mid-turn…`): about the root's *own* turn first — history up to the last completed tool call is intact, the in-flight call was cut off and its side effects may or may not have landed (check before repeating anything destructive), continue the task from there; don't start over, don't wait for input. Sub-agents come second (stopped-but-revivable via `query_session`; not listed — the root's own transcript / `session_list` has them). The first version talked only about sub-agents, so a root killed mid tool-loop went hunting for stopped children, found old ones, and declared "nothing was interrupted" instead of resuming.
|
|
194
|
-
- **Root idle, only sub-sessions mid-run** (text starts `[System] The server restarted at <ISO>. You were idle, but these sessions in your session tree were mid-run and were cut off…`): lists the cut-off ids of the root's tree — children and deeper (sorted, at most 10, then `(+N more)`) and says to re-dispatch with `query_session` if it was waiting on their reports, otherwise carry on. Sending the mid-turn text here sent an idle root looking for an in-flight tool call of its own that never existed. Skipped per root: row missing or `archivedAt` set; existing goal-mode bindings (a session with a `goal` column set, or a worker whose bound goal is still `running` — the retained `sweepActiveGoals` owns those nudges, unchanged); `
|
|
194
|
+
- **Root idle, only sub-sessions mid-run** (text starts `[System] The server restarted at <ISO>. You were idle, but these sessions in your session tree were mid-run and were cut off…`): lists the cut-off ids of the root's tree — children and deeper (sorted, at most 10, then `(+N more)`) and says to re-dispatch with `query_session` if it was waiting on their reports, otherwise carry on. Sending the mid-turn text here sent an idle root looking for an in-flight tool call of its own that never existed. Skipped per root: row missing or `archivedAt` set; existing goal-mode bindings (a session with a `goal` column set, or a worker whose bound goal is still `running` — the retained `sweepActiveGoals` owns those nudges, unchanged); `internal: true` agents (synchronous read of `agent.yaml`, since the sweep runs from a sync constructor). Principle: exactly one entry point gets woken per tree — a plain tree wakes its root, a `running` goal tree (existing binding) wakes its goal session. A cron run's root (`cron-*`, or a picked session) is a plain tree since 2.2 — cron runs in-process, its `{cron}` `reply_to` survives the restart, and the runner's boot recovery waits for these nudges before re-tracking the run (see [cron.md](cron.md#recovery-on-boot-recoverruns)).
|
|
195
195
|
|
|
196
196
|
**Eager boot** (`index.ts`): rather than waiting for someone to open a workspace, right after constructing `SessionManagerRegistry` (before `bootChannels`) the server calls `listRunningWorkspaces()` and for each workspace with leftover rows whose `.halo` dir still exists: `claimWorkspaceRuntime(ws)` first — if another live server owns it, `continue` (rows stay for a later boot, and no SessionManager is built, since caching a non-owner SM would freeze "not owner" for the whole process lifetime) — otherwise `registry.getOrCreate(ws)`, which fires the same constructor chain (claim → reconcile → retained `sweepActiveGoals` → `sweepInterruptedRuns`). An interrupted root with nobody around is exactly the one that must nudge itself without a human opening the tab first.
|
|
197
197
|
|
|
@@ -251,6 +251,8 @@ A hard interrupt aborts the turn's `AbortSignal`, which `agent-loop.ts` forwards
|
|
|
251
251
|
- **Why not `execAsync(command, { signal })`** (the previous implementation): `exec` wraps the command in `/bin/sh -c "<command>"` and, on abort, only SIGTERMs that `sh`. A compound command (`sleep 60 && …`) has already forked the real worker (`sleep`) as a child of `sh` — the signal never reaches it, so it **reparents to init (PPID 1) and runs to completion as an orphan**. The agent turn unwinds correctly (the promise rejects), but the command keeps running, which made `interrupt_session` *look* like it didn't really interrupt. Single non-compound commands didn't expose it (Node optimizes them to a direct `exec` with no `sh` layer).
|
|
252
252
|
- **`workspace` / `readonly` access** run under `bwrap` with `--die-with-parent`, a kernel-level guarantee that the sandboxed child dies with its parent — that path was never affected and is unchanged. On macOS they run under Seatbelt (`sandbox-exec`) through the same `spawnGroupExec` group kill. **Windows** `full` keeps the `execAsync` path (no process groups; the orphan case doesn't arise the same way).
|
|
253
253
|
|
|
254
|
+
A cron run's timeout reaches its running commands the same way (`stopSession` → abort); a command detached on purpose (`nohup … &`, `setsid`) escapes it, as it does a Stop. A server killed outright (SIGKILL) doesn't signal the groups of `full` commands either — they run to completion on their own.
|
|
255
|
+
|
|
254
256
|
The `spawnGroupExec` helper preserves `promisify(exec)`'s contract exactly: resolve `{ stdout, stderr }` on exit 0; reject with an `Error` carrying `.message` / `.stdout` / `.stderr` / `.code` otherwise (abort rejects with `name: 'AbortError'`). Covered by `test/sandbox-process-group.test.ts`, which asserts by side effect — a sentinel file the orphaned worker *would* create must never appear after the abort.
|
|
255
257
|
|
|
256
258
|
### Event routing
|
|
@@ -382,7 +384,7 @@ Frontend network issues don't affect the backend:
|
|
|
382
384
|
2. **Grace period**: fixed `config.timeout.sessionGrace` (5 min default) — a single `setTimeout`, no auto-extension
|
|
383
385
|
3. **Event buffering**: the detached handler uses `bufferDetachedNotification` to buffer structural events in `pendingEvents[]`. All state (messageLog / tokens / etc.) lives in SessionManager's UIState, not duplicated in the handler.
|
|
384
386
|
4. **Reconnect**: the admin resubscribes only the chat tab on screen; background tabs drop their stores and subscribe when next shown. A `subscribe` with a detached sessionId loads UIState from SessionManager, replays `pendingEvents`, and — if still running — resumes live streaming
|
|
385
|
-
5. **Detach-save dirty gate**: every WS write-back path (disconnect cleanup, grace expiry, `unsubscribe` / workspace switch, stop, chat error) funnels through `saveSession()`, which skips any UIState that isn't **dirty** — i.e. this process never reduced an event / appended a notification / replaced the log since the last successful persist (`SessionUIStore.isUIStateDirty`). A clean state is either a pure disk seed (built by `prepareForView` to *view* a session another process is driving — e.g. a
|
|
387
|
+
5. **Detach-save dirty gate**: every WS write-back path (disconnect cleanup, grace expiry, `unsubscribe` / workspace switch, stop, chat error) funnels through `saveSession()`, which skips any UIState that isn't **dirty** — i.e. this process never reduced an event / appended a notification / replaced the log since the last successful persist (`SessionUIStore.isUIStateDirty`). A clean state is either a pure disk seed (built by `prepareForView` to *view* a session another process is driving — e.g. a `halo cli -s` run appending to the same file; before 2.2, every cron run) or already flushed; writing it back would clobber the fresher file with a frozen snapshot. This was the cron-session UI-log truncation incident: admin viewed a cron session, closed the tab, and the grace-expiry save erased the cli child's final minutes of messages (`rawMessages`/`output` survived only via `saveSessionToFile`'s read-merge). A sub-session event changes only that sub's own file, so it does not mark the root dirty; `flushSession` likewise rewrites the root's file only when it is dirty. The flag is cleared on successful root `persistLog` (kept on failure so a later save retries) and on `dropUIState`/purge/`prepareForView` re-seed so a stale flag never leaks onto a fresh disk copy.
|
|
386
388
|
|
|
387
389
|
## Resilient execution loop
|
|
388
390
|
|
|
@@ -507,7 +509,7 @@ Bytes, not an exchange count, because bytes are the actual problem — file size
|
|
|
507
509
|
|
|
508
510
|
Segments are immutable. Deletion is filesystem-driven: deleting a session lists its directory and removes every file of the session (`ui.jsonl`, `raw.jsonl`, `ui.arch.*`, leftover `.tmp.*`, 1.x `.json` / `arch.*.json.gz`) rather than trusting any count or in-memory map, so crash-orphaned segments go too.
|
|
509
511
|
|
|
510
|
-
**Read side (scroll-up loading).** The admin loads a session's whole active UI log and fetches segments only when the user scrolls to the top: `GET /api/sessions/logs/:id/archive/:n?projectId=` returns `{ messages }` for one whole segment, gunzipped server-side (`:id` is the encoded full id, so a sub-session's segments are reachable too). The anchor is `archiveCount`, delivered on the `state:snapshot` of **subscribe / reattach only** (one segment count per session open); per-turn snapshots
|
|
512
|
+
**Read side (scroll-up loading).** The admin loads a session's whole active UI log and fetches segments only when the user scrolls to the top: `GET /api/sessions/logs/:id/archive/:n?projectId=` returns `{ messages }` for one whole segment, gunzipped server-side (`:id` is the encoded full id, so a sub-session's segments are reachable too). The anchor is `archiveCount`, delivered on the `state:snapshot` of **subscribe / reattach only** (one segment count per session open); per-turn snapshots deliberately omit it (`exchange:delete` answers with `exchange:deleted`, no snapshot). The client walks `archiveCount` → 1 and stops at "no earlier messages"; pulled segments are cached (immutable, so never re-requested) and a failed fetch keeps the cursor so the same segment can be retried.
|
|
511
513
|
|
|
512
514
|
**The anchor moves up, never down.** `noteArchiveAnchor` no-ops for an equal or lower count (keeping per-turn snapshots — which omit the field, read as 0 — and ordinary re-subscribes idempotent), but a **higher** count re-anchors. Needed because a turn that ends while the session is open can archive a new segment, and the next subscribe/reattach snapshot both carries the higher count *and* replaces the view with the shrunken active log: with a permanently pinned anchor those just-archived turns were in neither place — gone from the view and below a cursor counting down from the old anchor — showing a hole until the user reopened the session. Re-anchoring restarts the walk instead of extending it (prepends are oldest-first, so a newer segment can't splice onto an already-pulled tail); the re-pull costs no requests because `segmentCache` still holds them. Archived history renders collapsed and **read-only** — no Delete button, which matches `deleteExchange` refusing archived logs.
|
|
513
515
|
|
|
@@ -51,7 +51,7 @@ Slack-specific config JSON fields: `botToken`, `appToken`, `botUserId`, `teamId`
|
|
|
51
51
|
The slack cron-dispatcher (`channels/slack/cron-dispatcher.ts`) registers itself with `cron/dispatcher.ts`'s registry at boot and **requires an explicit `chatId`** (format: `D0123` for a DM, `C0123` for a channel, `C0123:1700.0` for a thread):
|
|
52
52
|
|
|
53
53
|
- Cron jobs created from inside a Slack chat via the `cron` skill auto-pin the current chat as `chatId`.
|
|
54
|
-
- Admin-UI cron jobs that don't specify a target run silently — the result
|
|
54
|
+
- Admin-UI cron jobs that don't specify a target run silently — nothing is pushed; the result is in the run history and the job's session.
|
|
55
55
|
- Slack has no "fan out to whitelist" model; every push needs an explicit destination.
|
|
56
56
|
|
|
57
57
|
## Modules
|
|
@@ -26,7 +26,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
26
26
|
│ │ ├── halo.db # their agent_sessions rows — global so they don't pollute any workspace's halo.db
|
|
27
27
|
│ │ └── <agentId>/<seg>.ui.jsonl … # same files as a workspace session (see Session files)
|
|
28
28
|
│ ├── evo.db # Cross-workspace evolution queue (evolution_runs + evolution_applies)
|
|
29
|
-
│ ├── cron.db # Cross-workspace cron jobs + run history (cron_jobs + cron_runs; schema in design/cron.md, migrations below)
|
|
29
|
+
│ ├── cron.db # Cross-workspace cron jobs + run history + webhook events (cron_jobs + cron_runs + cron_events; schema in design/cron.md, migrations below)
|
|
30
30
|
│ ├── runs.db # Run ledger: (workspace, session_id) rows for server-driven runs in flight; steady state empty (running_sessions)
|
|
31
31
|
│ ├── a2a.db # A2A: inbound tasks, push configs + outbox, outbound dispatches (see design/a2a.md)
|
|
32
32
|
│ ├── extensions/<id>/ # Installed Canvas preview extensions (halo-extension.json + bundle)
|
|
@@ -36,7 +36,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
36
36
|
│ │ └── silero-vad-v4/silero_vad.onnx
|
|
37
37
|
│ ├── logs/ # Runtime logs (server.log)
|
|
38
38
|
│ │ ├── evo/ # Per-evo-run wrapper logs
|
|
39
|
-
│ │ └── cron/ #
|
|
39
|
+
│ │ └── cron/ # Pre-2.2 cron cli-child stdout/stderr (in-process runs write none; 30-day retention)
|
|
40
40
|
│ ├── server.lock # Single-instance pid lock
|
|
41
41
|
│ └── .template-version # Template-seed marker (used by ensureHaloHome)
|
|
42
42
|
├── secrets/ # Sensitive files (not mounted into sandbox)
|
|
@@ -67,9 +67,11 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
67
67
|
├── evo/ # Self-evolution per-workspace artifacts
|
|
68
68
|
│ ├── runs/<id>/ # per-evaluation: source-snapshot.json, tool-flow.md,
|
|
69
69
|
│ │ # evo-context.json, sandbox/, patch.md, score.json,
|
|
70
|
-
│ │ # sub-cli.log, optional .skip.md
|
|
70
|
+
│ │ # sub-cli.log, optional .skip.md, auto-apply.json (L2+ verdict)
|
|
71
71
|
│ ├── applies/<id>/ # per-apply: meta.json, sandbox/, regress/<runId>/, apply.log
|
|
72
|
-
│ ├── history/apply-<id>/ # pre-apply rollback snapshot
|
|
72
|
+
│ ├── history/apply-<id>/ # pre-apply rollback snapshot: MANIFEST.json (per-file kind + sha256
|
|
73
|
+
│ │ # before/after + unchanged, createdDirs) + the overwritten files;
|
|
74
|
+
│ │ # after a rollback also rollback-pre/ (main just before) + ROLLBACK.json
|
|
73
75
|
│ └── archive/ # zipped runs/applies past the retention window
|
|
74
76
|
├── tmp/ # Agent scratch files (logs, downloads, intermediate artifacts) — convention from TOOL_GUIDELINES
|
|
75
77
|
├── assets/<channel>/inbound/<accountId>/<date>/ # Inbound media per channel (image/voice/video/file)
|
|
@@ -439,16 +441,16 @@ A value of the form `<<ENV_NAME>>` is replaced at read time with `process.env.EN
|
|
|
439
441
|
| `general.logging.level` | warn | `debug` / `info` / `warn` / `error`. Restart required |
|
|
440
442
|
| `general.a2a.public_url` | `''` | Global-only. Origin peers use to reach this server for A2A (no trailing slash): the agent card's interface URL and the default push-webhook base for `a2a_send`. Empty = derived from the request host (no outbound dispatch without it or a remote's `push_base`). Env `HALO_A2A_PUBLIC_URL` overrides. See [a2a.md](a2a.md) |
|
|
441
443
|
| `general.a2a.url_allowlist` | `100.64.0.0/10,*.ts.net` | Global-only. CIDRs / host patterns A2A egress may reach besides public https (push webhooks, remote cards and RPC, inbound image URLs). Private / loopback only when listed; plain http only to listed hosts / ranges; link-local always refused. See [a2a.md](a2a.md#push-delivery-and-url-policy) |
|
|
442
|
-
| `general.observability.endpoint` | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`); empty = off. Restart required (server
|
|
443
|
-
| `general.observability.service_name` | `halo` | OTel resource `service.name`. Restart required (server
|
|
444
|
-
| `general.observability.headers` | `''` | **Secret.** Extra OTLP request headers, comma-separated `k=v`. Restart required (server
|
|
445
|
-
| `general.observability.capture_content` | `false` | Put prompt/completion/tool text on spans. Restart required (server
|
|
444
|
+
| `general.observability.endpoint` | `''` | OTLP base URL of an OpenTelemetry collector (e.g. `http://localhost:4318`); empty = off. Restart required (server, cron runs included); evolution runs use a new value on their next run. See [observability.md](observability.md) |
|
|
445
|
+
| `general.observability.service_name` | `halo` | OTel resource `service.name`. Restart required (server, cron included); evolution: next run |
|
|
446
|
+
| `general.observability.headers` | `''` | **Secret.** Extra OTLP request headers, comma-separated `k=v`. Restart required (server, cron included); evolution: next run |
|
|
447
|
+
| `general.observability.capture_content` | `false` | Put prompt/completion/tool text on spans. Restart required (server, cron included); evolution: next run |
|
|
446
448
|
|
|
447
449
|
Schema source: [packages/server/src/settings-schema.ts](../../../packages/server/src/settings-schema.ts) `generalSection()`.
|
|
448
450
|
|
|
449
451
|
### Caching
|
|
450
452
|
|
|
451
|
-
`config.ts` reads settings.yaml lazily with mtime-watching: every read stats the file and reparses if mtime changed. UI saves bump mtime, so the server picks up new values on the next read **without a restart** — except the keys marked "Restart required" above plus `general.agent.max_retries`, which `config.ts` reads once into object literals at boot (observability: `initObservability()`, mapped onto `OTEL_*` env; the
|
|
453
|
+
`config.ts` reads settings.yaml lazily with mtime-watching: every read stats the file and reparses if mtime changed. UI saves bump mtime, so the server picks up new values on the next read **without a restart** — except the keys marked "Restart required" above plus `general.agent.max_retries`, which `config.ts` reads once into object literals at boot (observability: `initObservability()`, mapped onto `OTEL_*` env; the evolution `halo cli` children don't inherit those settings-derived vars (cron runs in the server process and follows its boot-time values) — `cleanChildEnv()` strips them — so each re-reads settings at its own start); their schema fields carry `restartRequired: true` and the Settings page shows a restart notice after saving one.
|
|
452
454
|
|
|
453
455
|
### Orphans
|
|
454
456
|
|
|
@@ -707,9 +709,9 @@ Rows are never pruned.
|
|
|
707
709
|
### Schema change rules
|
|
708
710
|
|
|
709
711
|
- `schema.sql` / each db's `CREATE_SQL` always describes the FULL current shape, so a fresh db is complete after the CREATEs alone
|
|
710
|
-
- Every change to an already-existing db gets a numbered slot in that file's ordered migration list — `HALO_MIGRATIONS` (`db/index.ts`), `CRON_MIGRATIONS` (`db/cron-db.ts`), `A2A_MIGRATIONS` (`db/a2a-db.ts`)
|
|
712
|
+
- Every change to an already-existing db gets a numbered slot in that file's ordered migration list — `HALO_MIGRATIONS` (`db/index.ts`), `CRON_MIGRATIONS` (`db/cron-db.ts`), `A2A_MIGRATIONS` (`db/a2a-db.ts`), `EVO_MIGRATIONS` (`db/evo-db.ts`); `channel-db.ts` / `runs-db.ts` currently pass an empty list. `runMigrations(sqlite, list)` (`db/migrate.ts`) runs `list[user_version..]` in order, each in its own transaction, stamping `PRAGMA user_version = i + 1` after each
|
|
711
713
|
- A fresh db is also at `user_version 0` and runs the whole list, so every slot must be a no-op against the current shape (`addColumnIfMissing`, `CREATE … IF NOT EXISTS`). A CHECK constraint can't be altered in SQLite: the slot rebuilds the table (create `_v2`, copy rows, drop, rename) and skips when `sqlite_master` already shows the new constraint — `HALO_MIGRATIONS` v2 (`disabled_items.scope` admits `'extension'`) is the pattern
|
|
712
|
-
- Current slots worth knowing: `HALO_MIGRATIONS` v2 — the `disabled_items` rebuild above (rows kept). `HALO_MIGRATIONS` v3 — `agent_sessions.output TEXT`, `agent_sessions.last_activity_at TEXT` (session store 2.0: the files carry no metadata). `CRON_MIGRATIONS` v5 — `cron_jobs.working_dir TEXT NULL` (job folder
|
|
714
|
+
- Current slots worth knowing: `HALO_MIGRATIONS` v2 — the `disabled_items` rebuild above (rows kept). `HALO_MIGRATIONS` v3 — `agent_sessions.output TEXT`, `agent_sessions.last_activity_at TEXT` (session store 2.0: the files carry no metadata). `CRON_MIGRATIONS` v5 — `cron_jobs.working_dir TEXT NULL` (the job's folder — the session's `working_dir`), `cron_jobs.stateless INTEGER NOT NULL DEFAULT 0` (1 = fresh session every run), `cron_runs.session_id TEXT NULL` (the session that run used). `CRON_MIGRATIONS` v6 — `cron_runs.workspace_path TEXT NULL` (the workspace the run fired against, where a stateless run's session lives), backfilled from the job row (`UPDATE … SET workspace_path = (SELECT workspace_path FROM cron_jobs …) WHERE workspace_path IS NULL`), so rows whose job is already gone stay NULL; column semantics in [cron.md](cron.md). `CRON_MIGRATIONS` v7 — `cron_jobs.webhook_secret TEXT NULL` (set = webhook-triggered job) plus the `cron_events` table (one row per webhook delivery, `UNIQUE(job_id, delivery_id)`; created by `CREATE_SQL`'s `IF NOT EXISTS`, so the slot itself only adds the column); cron.db at `user_version` 7 after it. `A2A_MIGRATIONS` v1 — `a2a_tasks.result_files TEXT`; v2 — `agentcore_invocations.result`, `result_files`, `superseded_by` (all `TEXT`, for a table an earlier build created without them; a2a.db at `user_version` 2 after it). `EVO_MIGRATIONS` v1 — `evolution_applies.approved_by TEXT NOT NULL DEFAULT 'reviewer'` (`'auto'` for L2/L3), `notified_at INTEGER`, `rolled_back_at INTEGER`; `evolution_runs.has_observation INTEGER NOT NULL DEFAULT 0`, `source_access TEXT` (`full` / `workspace` / `readonly`, or `webhook` for a session that took cron webhook events; null on pre-existing rows → never auto-applied); v2 — `evolution_runs.attempts_base INTEGER NOT NULL DEFAULT 0` (`attempts` at the latest retry; the retry budget counts from it), and v1's adds re-run (idempotent) for dev dbs stamped v1 without `source_access`; see [evolution.md](evolution.md)
|
|
713
715
|
- Append only — never reorder or edit a shipped slot. A db opened by a newer halo (user_version > list length) logs a warning and continues
|
|
714
716
|
- Do not drop or rename existing columns
|
|
715
717
|
- New channel types: add rows with a new `channel_type` value + define config shape in the channel's `accounts.ts` adapter
|
|
@@ -731,3 +733,4 @@ Rows are never pruned.
|
|
|
731
733
|
| v1+ | 2026-10-13 | `CRON_MIGRATIONS` v6 (additive): `cron_runs.workspace_path`, backfilled from the job row (NULL where the job is already gone). |
|
|
732
734
|
| v2 | 2026-10-15 | Session store 2.0: `{seg}.json` replaced by append-only `{seg}.ui.jsonl` (`add` / `patch` / `inflight` records) + `{seg}.raw.jsonl`; archive segments `{seg}.ui.arch.{N}.{h}.jsonl.gz`, committed count = segment files (no stored counter), archived at turn end over 5 MB; no metadata in files — `HALO_MIGRATIONS` v3 adds `agent_sessions.output` / `last_activity_at`, internal sessions get rows in `~/.halo/global/internal-sessions/halo.db`; `SessionMessage.compactSummary`. 1.x files are converted on first open (or `halo sessions migrate v1 v2`) and deleted after a read-back check. |
|
|
733
735
|
| v2 | 2026-10-17 | AgentCore `/invocations` (additive): `agentcore_invocations` in the workspace `a2a.db`; `A2A_MIGRATIONS` v2 adds `result`, `result_files`, `superseded_by`. `reply_to` gains `{ invocation }`. Session-dirs mode lays out `<base>/{.template,<sid>}/`. |
|
|
736
|
+
| v2 | 2026-11-05 | Cron in-process (2.2, additive): `CRON_MIGRATIONS` v7 adds `cron_jobs.webhook_secret` + the `cron_events` table. `reply_to` gains `{ cron: runId }`. New runs write no `logs/cron/` file. |
|
|
@@ -65,7 +65,7 @@ Halo can send to a Telegram chat without that user having messaged the bot first
|
|
|
65
65
|
The telegram cron-dispatcher (`channels/telegram/cron-dispatcher.ts`) registers itself with `cron/dispatcher.ts`'s registry at boot and **requires an explicit `chatId`** (numeric — Telegram private-chat ids equal user ids, group ids are negative):
|
|
66
66
|
|
|
67
67
|
- Cron jobs created from inside a telegram chat via the `cron` skill auto-pin the current chat id (target `telegram:<account>:<chatId>`).
|
|
68
|
-
- Admin-UI cron jobs that don't specify a target run silently — the result
|
|
68
|
+
- Admin-UI cron jobs that don't specify a target run silently — nothing is pushed; the result is in the run history and the job's session.
|
|
69
69
|
- `@username` entries are not accepted by `sendMessage`; the dispatcher rejects non-numeric values with a clear error.
|
|
70
70
|
|
|
71
71
|
Earlier drafts considered "fan-out to every id in `allowedUsers`" or "fall back to `lastActiveChatId`", but in practice both pushed the cron output to strangers' chats. The cron creator's intent is "reach me, the person who set this up" — so the only target is what the caller passes explicitly.
|
|
@@ -9,6 +9,7 @@ File: `packages/server/src/ws/handler.ts`
|
|
|
9
9
|
- Endpoint: `ws://localhost:9527/ws`
|
|
10
10
|
- Auth: `verifyClient` callback validates the JWT cookie
|
|
11
11
|
- All messages are JSON-encoded
|
|
12
|
+
- Compression: permessage-deflate (`perMessageDeflate: { zlibDeflateOptions: { level: 1 }, threshold: 1024 }` in `index.ts`) — frames ≥ 1 KB are compressed when the client offers the extension (browsers do), so a multi-MB subscribe snapshot ships ~4× smaller; stream deltas stay raw. Context takeover is on (~300 KB of zlib state per connection, allocated on the first compressed frame). Only the admin `/ws` server; the node / transcribe / stt / jupyter upgrade routes don't compress
|
|
12
13
|
|
|
13
14
|
### Server-side keepalive
|
|
14
15
|
|
|
@@ -66,7 +67,7 @@ Both directions are typed in `packages/core/src/protocol/ws-frames.ts` (`WsClien
|
|
|
66
67
|
| `chat:stop` | Hard-abort the current generation (ends the turn, no re-run) → `stopUserSession`. During a manual `/compact` (compacting, no turn running) it cancels the compact instead (`cancelCompact`, history rolls back). During an auto-compact — which runs inside a turn and is not cancellable — the abort takes effect once the compact finishes. |
|
|
67
68
|
| `chat:interrupt` | Interrupt the in-flight turn now (aborts a command mid-run); the server then folds any queued messages into one follow-up turn → `interruptSession`. Admin chat esc maps to this. Same compact semantics as `chat:stop`: a manual `/compact` with no turn running is cancelled; an auto-compact finishes first, then the interrupt lands. |
|
|
68
69
|
| `session:delete` | Delete session files + cascade-delete descendants in SQLite (handled inline), then release this connection's listener for it (no save — the session is gone) |
|
|
69
|
-
| `exchange:delete` | Delete one exchange (a user turn + its responses): **soft-delete** in the UI log (`deleted: true` markers, kept visible/greyed) + **physical-delete** the turn's copy from `rawMessages` (LLM context; located by the row's `stamp` — a row from before stamps by its text, among old rows and turns only) → `deleteExchange`. Fields: `userOrdinal` (0-based index among *main-conversation* user turns, i.e. excluding `taskId` sub-agent messages — matches the admin's `isMainConversationMessage` count), `archiveCount` (the archived-segment count the client's view was opened against — its archive anchor, from the `state:snapshot` it got on subscribe), optional `sessionId` / `projectId`. Rejected (→ `error`) while the session is running or compacting. The server also compares `archiveCount` against the archive segments on disk (`countArchiveSegments`); only a **mismatch** is refused, with `code: 'archived'` ("… archived history since it was opened — reopen it to delete individual turns") — a session with archives can still be deleted from as long as the anchor matches. A turn after the last compaction whose raw copy can't be located (a stamped row whose block raw doesn't hold — never a text fallback; an unstamped row merged with others, or its text not found), a stamp shared by two rows or two raw blocks, or a summary row is refused with `code: 'raw_unmatched'`, nothing changed. See [session.md](session.md#exchange-deletion-soft-ui--hard-raw). |
|
|
70
|
+
| `exchange:delete` | Delete one exchange (a user turn + its responses): **soft-delete** in the UI log (`deleted: true` markers, kept visible/greyed) + **physical-delete** the turn's copy from `rawMessages` (LLM context; located by the row's `stamp` — a row from before stamps by its text, among old rows and turns only) → `deleteExchange`. Fields: `userOrdinal` (0-based index among *main-conversation* user turns, i.e. excluding `taskId` sub-agent messages — matches the admin's `isMainConversationMessage` count), `archiveCount` (the archived-segment count the client's view was opened against — its archive anchor, from the `state:snapshot` it got on subscribe), optional `sessionId` / `projectId`. On success the server answers this connection — when it is subscribed to the session — with `exchange:deleted` (ids only, no snapshot). Rejected (→ `error`) while the session is running or compacting. The server also compares `archiveCount` against the archive segments on disk (`countArchiveSegments`); only a **mismatch** is refused, with `code: 'archived'` ("… archived history since it was opened — reopen it to delete individual turns") — a session with archives can still be deleted from as long as the anchor matches. A turn after the last compaction whose raw copy can't be located (a stamped row whose block raw doesn't hold — never a text fallback; an unstamped row merged with others, or its text not found), a stamp shared by two rows or two raw blocks, or a summary row is refused with `code: 'raw_unmatched'`, nothing changed. See [session.md](session.md#exchange-deletion-soft-ui--hard-raw). |
|
|
70
71
|
| `command:<name>` | Route through shared `dispatchCommand` (see [command.md](command.md)); `/session compact` handled inline for UI callbacks |
|
|
71
72
|
| `terminal:start` | Spawn a new PTY in `workspacePath`, named `name` (the tab name every page shows); pushed to the workspace's other subscribers as `terminal:opened` |
|
|
72
73
|
| `terminal:input` | Send keystrokes; the sender becomes the PTY's size driver (PTY switches to the size it last reported). `terminalId` is **required** — an id-less frame is logged and dropped (it used to fall back to the first entry of the process-global terminal map, which could write into a PTY of another workspace) |
|
|
@@ -121,13 +122,14 @@ Sub-agent text is root-tree noise on this wire: the listener is the root's (sub
|
|
|
121
122
|
|
|
122
123
|
`chat:system` producers (`session-manager.ts`'s `stop` event handling): a `max_tokens` stop emits `⚠️ [<agent>] Response truncated: output token limit reached.`; a `refusal` stop (Anthropic `stop_reason: "refusal"`, HTTP 200 — the model declined, not an error) emits `⚠️ [<agent>] Model declined to respond (<category>): <explanation> — …` suggesting `/session new` (see [session.md](session.md#resilient-execution-loop)).
|
|
123
124
|
|
|
124
|
-
Server-internal flags on `AgentSessionEvent` that are **not** carried into the WS frame: `stream.final` (marks the turn's wrap-up text vs. pre-tool filler — consumed by channel responders and the cli, see [session.md](session.md#message-queue-and-drain)), `stream.streamed` / `thinking.streamed` (the text already went out as deltas — decides whether the whole event is forwarded at all). The admin renders every streamed block, so none is needed on the wire.
|
|
125
|
+
Server-internal flags on `AgentSessionEvent` that are **not** carried into the WS frame: `stream.final` (marks the turn's wrap-up text vs. pre-tool filler — consumed by channel responders and the cli, see [session.md](session.md#message-queue-and-drain)), `stream.streamed` / `thinking.streamed` (the text already went out as deltas — decides whether the whole event is forwarded at all), `system.outOfBand` (a `notifySystem` notice from outside the turn — the evo auto-apply line; still a plain `chat:system` here, only the cron run and `halo cli` leave it out of their printed text). The admin renders every streamed block, so none is needed on the wire.
|
|
125
126
|
|
|
126
127
|
### Other Server → Client messages
|
|
127
128
|
|
|
128
129
|
| Type | Source | Purpose |
|
|
129
130
|
|---|---|---|
|
|
130
|
-
| `state:snapshot` | handler.ts on connect | Initial state (agents, messages, sessionId). On **subscribe / reattach only** it also carries `archiveCount` — the number of committed UI-log archive segments, which the admin's scroll-up loader counts down from (see [Archive anchor](#archive-anchor-in-statesnapshot)) — and `accessLevel` (`'workspace' | 'readonly' | null`, null = full), which seeds the admin's access-level selector. Subscribe includes `accessLevel` only when the session already exists; for a not-yet-created session it's omitted, and the admin leaves the selector alone when the field is absent (so a level picked before the first send isn't reset) |
|
|
131
|
+
| `state:snapshot` | handler.ts on connect | Initial state (agents, messages, sessionId). On **subscribe / reattach only** it also carries `archiveCount` — the number of committed UI-log archive segments, which the admin's scroll-up loader counts down from (see [Archive anchor](#archive-anchor-in-statesnapshot)) — and `accessLevel` (`'workspace' | 'readonly' | null`, null = full), which seeds the admin's access-level selector. Subscribe includes `accessLevel` only when the session already exists; for a not-yet-created session it's omitted, and the admin leaves the selector alone when the field is absent (so a level picked before the first send isn't reset). The subscribe / reattach rows leave out the log's `tool_call` / `tool_result` rows (`adminSnapshotRows`) — the chat renders each tool call from the assistant row's `contentBlocks`; usage / context rows stay. The on-disk UI log and the other channels keep them. The admin reuses the objects of unchanged rows on replace (`reconcileMessages`), so memoized rows skip re-render |
|
|
132
|
+
| `exchange:deleted` | `handleExchangeDelete` (this client, when subscribed to the session) | An `exchange:delete` landed — `{sessionId, deletedIds, userOrdinal, userRows}`: `deletedIds` is every soft-deleted row id of the active log (idempotent), `userOrdinal` the turn just deleted, `userRows` the log's user rows without `taskId` (deleted ones included — the count the ordinal runs over). The admin flags those rows plus the `userOrdinal` turn's span (rows this tab created live carry client ids the server log doesn't know) — no log replace. When the tab's own count differs from `userRows` (a turn another connection sent with `localEcho`, a `sendFailed` bubble) it flags the ids only and re-sends `subscribe`, whose snapshot corrects the log |
|
|
131
133
|
| `chat:ack` | `handleChat` | Chat with `clientMsgId` is persisted in the session log — releases the client's pending-ack entry (see [Chat delivery](#chat-delivery-ack--resend--dedup)) |
|
|
132
134
|
| `__pong__` | `__ping__` handler | Reply to the client's application-level liveness probe |
|
|
133
135
|
| `listener:released` | `reclaimIfAbandoned` (this client only) | One of this connection's event listeners was reclaimed (silent >3 min / CLOSED) — `{sessionId}` (never null), one frame per released session. Client must re-`subscribe` to reattach; the admin does so only for the tab on screen. See [Abandoned-listener reclaim](#abandoned-listener-reclaim-and-the-__ping__-contract). |
|
|
@@ -141,8 +143,8 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
|
|
|
141
143
|
| `chat:stopped` | `chat:stop` / `chat:interrupt` handlers (this client only) | Stop/interrupt acknowledged — `{sessionId}` |
|
|
142
144
|
| `session:compacted` | compact handler | Compaction complete — `{message?, contextTokens, sessionId}` |
|
|
143
145
|
| `compact:started` / `compact:summarizing` / `compact:done` | compact handler; `ws/event-processor.ts` | Compaction progress, `{sessionId}`. An auto-compact whose LLM summary failed falls back to a local compact and closes with a `system` event carrying `compactEnd`, which also emits `compact:done` (the success path closes via `compacted`). |
|
|
144
|
-
| `cron:job_changed` / `cron:run_changed` | `cron/runner.ts` + `routes/cron.ts` (broadcast) | Cron job/run state changed — the
|
|
145
|
-
| `evolution:run_changed` / `evolution:apply_changed` | `evolution/ticker.ts` + `routes/evolution.ts` (broadcast) | Evolution run/apply state changed — drives the Evolution tab. See [evolution.md](evolution.md). |
|
|
146
|
+
| `cron:job_changed` / `cron:run_changed` | `cron/runner.ts` + `routes/cron.ts` (broadcast) | Cron job/run state changed — the Triggers (cron) tab re-fetches instead of polling. `cron:job_changed`: `{jobId, kind?}` (`created` / `updated` / `deleted` / `reconciled` / `missed`; a run's finalize sends `lastRunStatus` + `lastRunAt` instead), plus — whenever the job still exists, except `missed` — `workingDir` (workspace-relative, `null` = root), `stateless` (boolean) and `sessionAgentId` (agent of the job's existing session, `null` when stateless / not created / unreadable; the job's agent is locked to it). `cron:run_changed`: `{jobId, runId, status}`; `status: 'deleted'` = that run row was withdrawn (a webhook fire's re-check found the session taken by another owner) — clients just re-fetch the list. See [cron.md](cron.md). |
|
|
147
|
+
| `evolution:run_changed` / `evolution:apply_changed` | `evolution/ticker.ts` + `routes/evolution.ts` (broadcast) | Evolution run/apply state changed — drives the Evolution tab. Includes the L2/L3 auto path (`running → approved` plus the new `approved_by='auto'` apply, pushed by the ticker's diff) and `rolled_back` (pushed by the rollback route). See [evolution.md](evolution.md). |
|
|
146
148
|
| `models:changed` | `models/watcher.ts` `reloadAndBroadcast` (broadcast to all clients) | `~/.halo/global/models.d/` changed (hub provider yamls installed by `halo models install` / `/extension models`, or removed by hand) and the merged registry differs from the last one — the server has already dropped its registry cache. No payload: the admin bumps its models bus, and the Agents panel / agent form, the composer's image-capability check and the Settings page re-fetch `/agent-configs/models` / the settings schema. Also bumped on WS reconnect (a frame lost while down). Global, no workspace marker. See [storage.md](storage.md#model-registry-format). |
|
|
147
149
|
| `skills:changed` | `ws/skills-changed.ts` `notifySkillsChanged` (REST `POST` / `DELETE /skills`, `PATCH /skills/:id/toggle`, `/skill disable` · `enable` · `delete`) · `ws/watcher-pool.ts` (the workspace watcher saw `.halo/skills/**` change) | A skill was created / deleted / toggled, or a workspace skill folder changed on disk (an agent's `file_write`, a hand edit). No payload: the admin bumps its skill bus, and the chat slash-command popup, the Skills page and the agent form's skill picker re-fetch. Scoped on the server: a workspace skill or a toggle (the disable flag lives in that workspace's db) goes via `broadcastToWorkspace`, a global skill via `broadcast`. Only real changes emit: a no-op mutation (409 / 404 / already disabled) sends nothing, and the watcher path compares a per-workspace key (folder ids + each SKILL.md's frontmatter), so body / resource edits and the echo of a mutation that already notified stay silent. The watcher debounce is 300 ms on top of the file watcher's own 300 ms flush. `~/.halo/global/skills/` has no watcher, so a hand edit there shows on the Skills page's window-focus refetch. Also bumped on WS reconnect while the Skills page is open. |
|
|
148
150
|
| `extension:changed` | `extensions/watcher.ts` `rescanAndBroadcast` (broadcast to all clients) | `~/.halo/global/extensions/` changed (install / upgrade / uninstall / on-off switch, from the admin upload, the one-click update, the `extension` skill, or a manual `mv`/`rm`; a switch rewrites `.state.json`, which trips the same watcher) — carries the **full** `ExtensionsSnapshot` (`{extensions, disabled, errors}`: switched on, switched off, refused), not a diff, and only fires when the snapshot key differs from the last one. The admin replaces its extension list wholesale (a missing `disabled`, from an older server, reads as `[]`): the preview registry re-resolves open tabs from `extensions` only (an upgraded extension remounts, or shows a banner if the tab is dirty; an uninstalled or switched-off one falls back to the "no built-in preview" page, or keeps a dirty tab alive with a banner) and the Settings → Extensions panel re-renders. Global, no workspace marker — extensions are server-wide. See [canvas-extensions.md](canvas-extensions.md). |
|
|
@@ -155,7 +157,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
|
|
|
155
157
|
|
|
156
158
|
### Archive anchor in `state:snapshot`
|
|
157
159
|
|
|
158
|
-
`archiveCount` rides only the snapshots sent on **subscribe** and **reattach** — one segment count per session open. The per-turn snapshots
|
|
160
|
+
`archiveCount` rides only the snapshots sent on **subscribe** and **reattach** — one segment count per session open. The per-turn snapshots deliberately omit it (and `exchange:delete` answers with `exchange:deleted`, no snapshot): the client's "load older" cursor is an anchor pinned at the moment the session was opened, and a segment written mid-session (a compact while the tab is open) must not move it. The client then requests segments over HTTP (`GET /api/sessions/logs/:id/archive/:n`), not WS — one whole segment per call, cached client-side because segments are immutable. See [session.md](session.md#ui-log-archiving).
|
|
159
161
|
|
|
160
162
|
The same anchor is what the client sends back as `archiveCount` on `exchange:delete`, so the server can tell a stale ordinal from a live one.
|
|
161
163
|
|
|
@@ -229,7 +231,7 @@ Teardown lives in one function (`cleanupConnection`) shared by the socket's `clo
|
|
|
229
231
|
Client reconnects and sends `subscribe`:
|
|
230
232
|
1. Server detects the detached session
|
|
231
233
|
2. Loads UIState from SessionManager (via `getUIState`) and builds a save snapshot
|
|
232
|
-
3. Sends `state:snapshot` — messages from `createSaveSnapshot(state)`, or the settled `messageLog` only when the session is still running (see step 6) — plus `archiveCount`, re-pinning the scroll-up anchor for the reopened log
|
|
234
|
+
3. Sends `state:snapshot` — messages from `createSaveSnapshot(state)`, or the settled `messageLog` only when the session is still running (see step 6), minus the `tool_call` / `tool_result` rows — plus `archiveCount`, re-pinning the scroll-up anchor for the reopened log
|
|
233
235
|
4. Replays buffered `pendingEvents`
|
|
234
236
|
5. Re-attach the event listener — live streaming continues seamlessly
|
|
235
237
|
6. If the session is still running, synthesizes the in-progress turn as an **authoritative replay**: every synthesized message carries `replay: true` — a `chat:followup`, then the turn's `turnContentBlocks` in order (`chat:thinking` / `chat:stream` / `agent:tool_call` + `agent:tool_result` if completed), each with the session's real `agentName` and the block's real `turnId` so usage badges / turn grouping survive the reconnect. The running-session snapshot in step 3 sends the settled `messageLog` only (no `createSaveSnapshot` temp in-flight message — the replay carries that turn instead).
|
package/bundled-docs/dev/api.md
CHANGED
|
@@ -227,11 +227,12 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
|
|
|
227
227
|
|
|
228
228
|
| Method | Path | Purpose |
|
|
229
229
|
|---|---|---|
|
|
230
|
-
| GET | `/api/cron/jobs?limit=&before=` | Cursor-paginated newest-first by `createdAt`. Default `limit` 20, max 100; `before` is a `createdAt` cursor. Returns `{jobs, hasMore, nextCursor}`. Each job has `targets` decoded, a live `nextRunAt` (computed from schedule/runAt, inside the active window), `sessionId` (`null` = runs in the job's own session), `effectiveSessionId` (the session the
|
|
231
|
-
| POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt
|
|
232
|
-
| PUT | `/api/cron/jobs/:id` | Partial update over the same fields. Re-validates `schedule` / `runAt` and enforces trigger-mode exclusivity on the **merged** row: both in one body → 400 `schedule and runAt are mutually exclusive`; supplying one side is a mode switch that implicitly clears the other; a body that clears the active side without supplying a replacement → 400 `schedule or runAt required`. `runAt` must be a finite future epoch-ms number or `null`. `timeoutSec` follows the same partial-body contract: omitted = unchanged, `null` = clear back to the default, a number is range-validated (60–21600) same as create. `timezone` likewise: omitted = unchanged, `null` / `''` = clear back to the host timezone, anything else is validated same as create (same two 400s). `sessionId` likewise: omitted = unchanged, `null` / `''` = back to the job's own session (an older job pinned to `cron-cron-…` then starts a fresh one), a string is validated same as create. `activeFrom` / `activeUntil` likewise (omitted = unchanged, `null` = clear); the window rules are checked on the **merged** row, so switching to `runAt` while a window is stored needs both bounds cleared in the same body, and a newly supplied `activeUntil` must be in the future (a stored past one doesn't block editing other fields). `workingDir` likewise (omitted = unchanged, `null` / `''` = back to the root); a stored folder is re-checked when `workspacePath` changes. `stateless` and the `sessionId` exclusivity are checked on the **merged** row. The agent-lock / read-error 400s (see POST) apply only when the body changes the stored agent, session, `stateless` or `workspacePath` — so an unrelated edit such as `{enabled:false}` always succeeds, even for a job whose session already runs another agent. Flips schedule/unschedule on `enabled`. Returns `{ok}` |
|
|
230
|
+
| GET | `/api/cron/jobs?limit=&before=` | Cursor-paginated newest-first by `createdAt`. Default `limit` 20, max 100; `before` is a `createdAt` cursor. Returns `{jobs, hasMore, nextCursor}`. Each job has `targets` decoded, a live `nextRunAt` (computed from schedule/runAt, inside the active window), `sessionId` (`null` = runs in the job's own session), `effectiveSessionId` (the session the run actually uses: `sessionId`, else the job's own — same id as the job, `cron-<id>` for a custom id without that prefix; older jobs were pinned to their original `cron-cron-…` session by a cron.db migration), `activeFrom` / `activeUntil` (`null` = unbounded), `workingDir` (workspace-relative folder the run focuses on; `null` = the workspace root), `stateless` (boolean; `true` = a fresh session every run), and `sessionAgentId` (the agent of the effective session once it exists — the job's agent is locked to it; `null` for a stateless job, a session not created yet, or a session that can't be read), `webhookSecret` (`null` unless a webhook job) and `webhookPath` (`/cron-hooks/<id>`, relative — prefix the public origin; `null` unless a webhook job) |
|
|
231
|
+
| POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt?, schedule, runAt?, webhook?, timezone?, timeoutSec?, sessionId?, workingDir?, stateless?, targets?, enabled?}`. Exactly one of `schedule` (5-field cron), `runAt` (epoch ms, future) or `webhook: true` is required (none → 400 `schedule, runAt or webhook required`; two → 400 `… mutually exclusive`). `webhook` (boolean, else 400) makes it a webhook-triggered job: the server generates its secret; a webhook job takes no active window. `userPrompt` is required (else 400 `userPrompt required`) except on a webhook job, where omitted / `''` is stored as `''` and each run opens with its events alone. It may pick a `sessionId` like any stateful job: its events wait while that session is owed to another owner (relay / A2A / an invocation / another job's run) and run once it is released. `timeoutSec` must be an integer 60–21600 (else 400); unset = runner default 3600. `timezone` is an IANA name (e.g. `Asia/Shanghai`); unset / `null` / `''` = the host timezone (see `/api/cron/meta`); a non-string → 400 `timezone must be a string`, a zone `Intl.DateTimeFormat` rejects → 400 `invalid timezone: <value>`. `sessionId` picks an existing (or to-be-created) **root** session of `workspacePath` for the runs to use — `[A-Za-z0-9_:-]{1,200}`, no `>` sub-session path (else 400); unset / `null` / `''` = the job's own session (id = the job id). A picked session that already exists keeps its own access level, and its agent must equal `agentId` (agent lock, below); see [design/cron.md](../design/cron.md#execution-runjob) for the shared-session guards. `activeFrom` / `activeUntil` (epoch ms or `null`, recurring only): the schedule fires only inside `[activeFrom, activeUntil)`; with `runAt` → 400; `activeUntil` must be in the future and after `activeFrom` (else 400); `activeFrom` may be in the past. `workingDir` (workspace-relative or absolute; unset / `null` / `''` = the root) must be an existing directory inside `workspacePath` (else 400 `workingDir: …`); stored workspace-relative. `stateless` (boolean, else 400) runs every fire in a fresh session; with `sessionId` → 400 `stateless (new session every run) and sessionId are mutually exclusive`. Agent lock: when the effective session already exists and runs another agent → 400 `session <id> already runs agent <x>; …`; a session db that can't be read → 400 `could not read session <id> from <ws>: …`. Returns `{ok, id}` |
|
|
232
|
+
| PUT | `/api/cron/jobs/:id` | Partial update over the same fields. Re-validates `schedule` / `runAt` and enforces trigger-mode exclusivity on the **merged** row: both in one body → 400 `schedule and runAt are mutually exclusive`; supplying one side is a mode switch that implicitly clears the other; a body that clears the active side without supplying a replacement → 400 `schedule, runAt or webhook required`. `webhook: true` switches to webhook mode (an existing secret is kept; with `schedule` / `runAt` in the same body → 400); `webhook: false`, or a `schedule` / `runAt` alone, switches away and drops the secret. Only a webhook job may have an empty `userPrompt`: a body that empties a timed job's prompt, or switches an empty-prompt webhook job to `schedule` / `runAt` without a non-empty `userPrompt`, → 400 `userPrompt required (only a webhook job may leave it empty)`. `runAt` must be a finite future epoch-ms number or `null`. `timeoutSec` follows the same partial-body contract: omitted = unchanged, `null` = clear back to the default, a number is range-validated (60–21600) same as create. `timezone` likewise: omitted = unchanged, `null` / `''` = clear back to the host timezone, anything else is validated same as create (same two 400s). `sessionId` likewise: omitted = unchanged, `null` / `''` = back to the job's own session (an older job pinned to `cron-cron-…` then starts a fresh one), a string is validated same as create. `activeFrom` / `activeUntil` likewise (omitted = unchanged, `null` = clear); the window rules are checked on the **merged** row, so switching to `runAt` while a window is stored needs both bounds cleared in the same body, and a newly supplied `activeUntil` must be in the future (a stored past one doesn't block editing other fields). `workingDir` likewise (omitted = unchanged, `null` / `''` = back to the root); a stored folder is re-checked when `workspacePath` changes. `stateless` and the `sessionId` exclusivity are checked on the **merged** row. The agent-lock / read-error 400s (see POST) apply only when the body changes the stored agent, session, `stateless` or `workspacePath` — so an unrelated edit such as `{enabled:false}` always succeeds, even for a job whose session already runs another agent. Flips schedule/unschedule on `enabled`. Returns `{ok}` |
|
|
233
233
|
| DELETE | `/api/cron/jobs/:id` | Unschedule, delete each stateless run's own session (except running / user-continued ones) and the job's `cron_runs` rows, then delete the job. A run still in progress keeps its row and session until it finalizes, then both go ([cron.md → Retention](../design/cron.md#retention)). Returns `{ok}` |
|
|
234
|
-
| POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget). Returns `{ok}` |
|
|
234
|
+
| POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget; on a webhook job the prompt runs alone, no events — with an empty prompt the run is recorded `skipped`). Returns `{ok}` |
|
|
235
|
+
| POST | `/api/cron/jobs/:id/webhook-secret` | Rotate a webhook job's secret — the old one stops working at once. 404 unknown job, 400 `not a webhook job`. Returns `{ok, webhookSecret}` |
|
|
235
236
|
| POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
|
|
236
237
|
|
|
237
238
|
`targets` is `Array<{channelType, accountId, chatId?}>`. New job ids look like `cron-<base36-ts>-<rand>`.
|
|
@@ -240,8 +241,12 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
|
|
|
240
241
|
|
|
241
242
|
| Method | Path | Purpose |
|
|
242
243
|
|---|---|---|
|
|
243
|
-
| GET | `/api/cron/jobs/:id/runs?limit=&before=` | Cursor-paginated newest-first by `cron_runs.id` (ISO-prefixed; sorts as text). Default `limit` 20, max 100. Returns `{runs, hasMore, nextCursor}`; each row has `dispatchResults` JSON-decoded and `sessionId` (the session the run used — a stateless job's per-run `cron-<id>-<base36 ms>`; `null` on runs from before the column, and on `skipped` rows) |
|
|
244
|
-
| GET | `/api/cron/runs/:runId/log` | Read the raw log file
|
|
244
|
+
| GET | `/api/cron/jobs/:id/runs?limit=&before=` | Cursor-paginated newest-first by `cron_runs.id` (ISO-prefixed; sorts as text). Default `limit` 20, max 100. Returns `{runs, hasMore, nextCursor}`; each row has `dispatchResults` JSON-decoded and `sessionId` (the session the run used — a stateless job's per-run `cron-<id>-<base36 ms>`; `null` on runs from before the column, and on `skipped` rows) and `eventCount` (webhook events the run handled) |
|
|
245
|
+
| GET | `/api/cron/runs/:runId/log` | Read the raw log file of a pre-2.2 (cli-child) run. Returns `{log}` (string, or null for an in-process run — those write none, the session is the record — and when the file is missing / past retention) |
|
|
246
|
+
|
|
247
|
+
### Webhook receiver
|
|
248
|
+
|
|
249
|
+
`POST /cron-hooks/:jobId` — outside `/api`, no admin cookie (`routes/cron-hooks.ts`; not mounted in AgentCore mode). Auth: GitHub `X-Hub-Signature-256: sha256=<HMAC-SHA256(secret, raw body)>` or `Authorization: Bearer <secret>`. Unknown job, not a webhook job and bad auth are one `404`. Body ≤ 1 MB (else 413), stored as received. GitHub `ping` → 200 `{ok, ping: true}` (even on a disabled job); disabled job → 409; 100 events already pending → 429. Delivery id = `X-GitHub-Delivery` / `Idempotency-Key` / `X-Request-Id`, else the body's sha256 — then the same body is a repeat for 10 minutes only (send a delivery id or `Idempotency-Key` for real dedupe); a repeat → 200 `{ok, duplicate: true, eventId}`, except a repeat of an event whose run failed → processed again, 202 `{ok, eventId, redelivered: true}`. Stored → 202 `{ok, eventId}`; the run starts (or the event joins the running one) without the caller waiting. Semantics: [cron.md → Webhook trigger](../design/cron.md#webhook-trigger).
|
|
245
250
|
|
|
246
251
|
### Reference data (for the create form)
|
|
247
252
|
|
|
@@ -354,10 +359,6 @@ File: `packages/server/src/nodes/routes.ts` (+ `hub.ts` for the WebSocket). Not
|
|
|
354
359
|
|
|
355
360
|
`GET /api/nodes/connect` + WebSocket upgrade, headers `authorization: Bearer <token>`, `x-halo-node-protocol: 1`, `x-halo-node-instance: <uuid>`, `x-halo-node-info: <base64url JSON>`. Refusals before the upgrade: 429 locked out (5 bad tokens / 15 min, `node-token` bucket) · 426 protocol · 401 token · 404 workspace gone · 400 info / instance missing · 409 connected from another instance. Frames and close codes: [design/node.md](../design/node.md#protocol-v1).
|
|
356
361
|
|
|
357
|
-
### Internal relay
|
|
358
|
-
|
|
359
|
-
`POST /api/nodes/call` — loopback only and header `x-halo-node-relay: <per-boot secret>` (else 403); in `PUBLIC_PATHS`, used by cron's `halo cli` child. Body `{workspace, op, node?, args?}` (`op: 'nodes'` = node_list) → `{ok: true, text}` or `{ok: false, error}`. Not a public API.
|
|
360
|
-
|
|
361
362
|
## A2A (Agent-to-Agent)
|
|
362
363
|
|
|
363
364
|
Files: `packages/server/src/a2a/routes.ts` (JSON-RPC handler), `exposure.ts` (path resolution, auth, card), `outbound.ts` (push receiver). A2A v1.0, JSON-RPC binding only. Mounted in normal server mode outside `/api/*` (no admin cookie) and before the static frontend; not in the CLI / TUI. AgentCore mode serves the same handler at `/` instead — see [AgentCore runtime container](#agentcore-runtime-container-halo-agentcore-only). Design: [design/a2a.md](../design/a2a.md).
|
|
@@ -528,13 +529,14 @@ File: `packages/server/src/routes/evolution.ts`. Surfaces the global `evolution_
|
|
|
528
529
|
|
|
529
530
|
| Method | Path | Purpose |
|
|
530
531
|
|---|---|---|
|
|
531
|
-
| GET | `/api/evolution/runs?archived=0\|1&limit=20&before=<createdAt>` | List evolution runs across workspaces, newest first, cursor-paginated (`limit` 1–300, `before` = previous page's `nextCursor`; `archived=1` lists archived runs instead). Returns `{runs, hasMore, nextCursor}`; each row carries the latest `applyId`/`applyStatus`. (Score isn't surfaced in the list — it's read from `score.json` on the detail fetch only.) |
|
|
532
|
-
| GET | `/api/evolution/runs/:id` | Detail: db row + `patch.md` + `score.json` (parsed as-is — includes the scorer's `checks` object when present, see [design/evolution.md](../design/evolution.md#phase-c--score)) + `.skip.md` (when `status='skipped'`) + wrapper/sub-cli logs + a snapshot summary (first user message + first assistant reply + message count). Row carries `failureReason` (and `applyFailureReason` from the latest apply) — apply-side values include `phase A': apply cli exited <code>`, `phase A': apply agent aborted: <first line of ABORT.md>`, and `phase A': apply agent didn't produce apply.log
|
|
533
|
-
| POST | `/api/evolution/runs/:id/approve` `{reviewerHint?}` | Move run from `awaiting_review` → `approved
|
|
532
|
+
| GET | `/api/evolution/runs?archived=0\|1&limit=20&before=<createdAt>` | List evolution runs across workspaces, newest first, cursor-paginated (`limit` 1–300, `before` = previous page's `nextCursor`; `archived=1` lists archived runs instead). Returns `{runs, hasMore, nextCursor}`; each row carries the latest `applyId`/`applyStatus`/`applyApprovedBy` (`reviewer` \| `auto`) and `hasObservation`. (Score isn't surfaced in the list — it's read from `score.json` on the detail fetch only.) |
|
|
533
|
+
| GET | `/api/evolution/runs/:id` | Detail: db row + `patch.md` + `score.json` (parsed as-is — includes the scorer's `checks` object when present, see [design/evolution.md](../design/evolution.md#phase-c--score)) + `.skip.md` (when `status='skipped'`) + wrapper/sub-cli logs + a snapshot summary (first user message + first assistant reply + message count). Row carries `failureReason` (and `applyFailureReason` from the latest apply) — apply-side values include `phase A': apply cli exited <code>`, `phase A': apply agent aborted: <first line of ABORT.md>`, and `phase A': apply agent didn't produce apply.log`, and for auto applies `auto_recheck:<reason>`. Also: `autoApply` (the L2+ verdict `auto-apply.json` — `level`, `threshold`, `sourceAccess`, `category`, `files[{rel, category, reason?}]`, `auto`, `reason`, `score`, `observation`; null below L2), `observation` (the draft's `## Larger observation`, or null), `rollback` (`{available, files[{rel, kind, state}]}` while the latest apply is `applied`, else null; states as in [Rollback](../design/evolution.md#rollback)) and `publishedFiles` (posix rels the latest apply effectively changed, for `applied` / `rolled_back`; else null). |
|
|
534
|
+
| POST | `/api/evolution/runs/:id/approve` `{reviewerHint?}` | Move run from `awaiting_review` → `approved` and insert a pending `evolution_applies` row (`approved_by='reviewer'`) in one transaction. Returns `{ok, applyId}`; 409 when the run isn't `awaiting_review` or already has an apply in flight / applied. (At L2/L3 the wrapper queues `approved_by='auto'` applies itself, without this route.) |
|
|
534
535
|
| POST | `/api/evolution/runs/:id/reject` | Move run from `awaiting_review` → `rejected`; 409 otherwise. |
|
|
535
|
-
| POST | `/api/evolution/runs/:id/retry` `{hint}` | Reset a finished run back to `pending` (attempts
|
|
536
|
+
| POST | `/api/evolution/runs/:id/retry` `{hint}` | Reset a finished run back to `pending` (retry budget restarted: `attempts_base = attempts`; the `attempts` the list / detail return is `attempts - attempts_base`, this round's count) with the supplied hint (required, else 400) so the wrapper picks it up again. Rejected with 409 when the run is `running` / `pending` / `applied` (roll back first), or `approved` while its latest apply isn't `failed` / `timeout`. |
|
|
536
537
|
| POST | `/api/evolution/runs/:id/hint` `{hint}` | Append text to `user_hint` (hint required, else 400) — memo only, doesn't change status. Returns `{ok, userHint}`. |
|
|
537
538
|
| DELETE | `/api/evolution/runs/:id` | Delete a finished run: its on-disk artifacts (run dir + archive zip + wrapper log) **and** the DB row, plus every apply that references it (artifacts + row, `evolution:apply_changed` `kind:'deleted'`). Rejected with 409 for in-flight states (`pending` / `running` / `approved`) so a live wrapper / queued apply isn't pulled out from under. Broadcasts `evolution:run_changed` with `kind:'deleted'`. |
|
|
539
|
+
| POST | `/api/evolution/applies/:id/rollback` `{force?}` | Undo an `applied` apply from its history snapshot: restore overwritten files, remove new ones and the dirs the publish created (kept, and listed in `keptDirs`, when someone has since put files in them). `200 {ok, restored, removed, removedDirs, keptDirs}`; the apply and its source runs become `rolled_back`, broadcast on both channels. 409 `{error}` for `not_applied` / `apply_in_flight` / `no_snapshot`; `{error:'conflict', conflicts[{rel, reason: modified\|missing\|unverified}]}` unless `force`; `{error:'backup_mismatch', files}` even with `force`. Files already back at their pre-apply state are skipped. See [design/evolution.md](../design/evolution.md#rollback). |
|
|
538
540
|
| GET | `/api/evolution/applies` | List in-flight apply rows (`pending` / `running` / `syncing`, newest 200; used for status badges). Returns `{applies}`. |
|
|
539
541
|
|
|
540
542
|
## AgentCore runtime container (`halo agentcore` only)
|
|
@@ -1103,6 +1105,7 @@ It does not remove session files. The full delete (rows + JSON + archive segment
|
|
|
1103
1105
|
| `chat:queued` | `reason: 'compact'`, `message`, `sessionId` | Message queued behind a compact |
|
|
1104
1106
|
| `chat:ack` | `clientMsgId` | Chat appended to the session log (or re-acked on resend) |
|
|
1105
1107
|
| `listener:released` | `sessionId` | Event listener reclaimed — re-send `subscribe` (see above) |
|
|
1108
|
+
| `exchange:deleted` | `sessionId`, `deletedIds`, `userOrdinal`, `userRows` | An `exchange:delete` this connection sent landed: ids of every soft-deleted row of the active log, the ordinal of the turn just deleted, and the log's user-turn count (without `taskId`) that ordinal runs over (the sender's own reply only — no full `state:snapshot` follows) |
|
|
1106
1109
|
| `skills:changed` | — | A skill was created / deleted / toggled, or a workspace skill folder changed on disk — re-fetch skill lists and `/api/commands`. Workspace-scoped changes reach only that workspace's sockets (see [Skills](#skills)) |
|
|
1107
1110
|
| `nodes:changed` | `online: [{accountId, root \| null, shell, os, since}]` | A `halo node` connected / disconnected — full snapshot of online machines (global broadcast; `root: null` = whole machine). See [Linked machines](#linked-machines-halo-node) |
|
|
1108
1111
|
|
package/bundled-docs/dev/env.md
CHANGED
|
@@ -120,7 +120,7 @@ Source: `packages/server/src/config.ts`
|
|
|
120
120
|
| `HALO_AGENTCORE_NOTIFY` | (none) | — | agentcore-a2a mode only: shell command (`sh -c`) run when a `POST /invocations` job finishes — stdin = the final reply, env + `HALO_SESSION_ID`, `HALO_RUN_STATUS` (`ok` \| `error`), `HALO_WORKSPACE` (also its cwd: the workspace; in session-dirs mode a root-only 0700 dir with the vetted MEDIA copies). Runs outside the agent sandbox, killed after 5 min (see [design/agentcore.md](../design/agentcore.md#completion-and-the-notify-hook)) |
|
|
121
121
|
| `HALO_AGENTCORE_SESSION_DIRS` | (none) | — | `1` (agentcore-a2a mode, root): one workspace per AgentCore runtime session id — `HALO_WORKSPACE` is the base, `<base>/<id>` (seeded from `<base>/.template`) is bound at `/workspace` and served by an unprivileged worker that gets only an env allowlist (see [design/agentcore.md](../design/agentcore.md#session-dirs)) |
|
|
122
122
|
| `HALO_DEFAULT_PROVIDER` | (none) | — | Read by `halo setup --non-interactive`: binds every built-in agent to this provider id (and its default model) and, for each of its env-backed keys whose variable is set, writes the `<<NAME>>` placeholder into `settings.yaml` (Bedrock excepted). An unknown id only prints a warning |
|
|
123
|
-
| `HALO_CLI` | `halo` (`halo.cmd` on Windows) | — | `halo` cli executable
|
|
123
|
+
| `HALO_CLI` | `halo` (`halo.cmd` on Windows) | — | `halo` cli executable the evolution wrapper spawns — a dev override (cron runs in-process since 2.2 and no longer uses it) |
|
|
124
124
|
| `HALO_VERSION` | `dev` | — | Version string reported by `GET /api/health` and the cli; stamped by the bundle / desktop launcher, so normally not set by hand |
|
|
125
125
|
| `HALO_NODE_TOKEN` | (none) | — | `halo node` only (on the linked machine): pairing token, used when `--token` isn't given — preferred over the flag, which shows up in `ps` / shell history. Stripped from child-process env (see [design/node.md](../design/node.md)) |
|
|
126
126
|
| `HALO_PROMPT` | (none) | — | `plain` makes `halo setup` use numbered-menu prompts instead of the raw-mode TUI |
|
|
@@ -139,7 +139,7 @@ settings.yaml only (no env override):
|
|
|
139
139
|
- `general.sandbox.writable_dirs` (default empty) — dirs writable inside the sandbox besides the workspace (Linux / macOS), for external CLIs that keep local state (e.g. `~/.kiro`); not applied to readonly sessions, scope: global
|
|
140
140
|
- `general.sandbox.hidden_files` (default empty) — extra files hidden from workspace/readonly sessions, read as empty files (bwrap bind of `~/.halo/.sandbox-empty` on Linux, Seatbelt deny on macOS), **appended** to the built-in list, which is always included (`~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/{evo,cron,runs,a2a}.db` + their `-wal`/`-shm` files); Full sessions, the admin file explorer and the terminal are not affected, scope: global
|
|
141
141
|
- `general.logging.level` (default `warn`) — log level: debug | info | warn | error, restart required
|
|
142
|
-
- `general.observability.endpoint` (default `''`) — OTLP collector base URL (e.g. `http://localhost:4318`); empty = off, scope: global, restart required (the server; `halo cli` / `halo tui` read all `general.observability.*` keys at start, so
|
|
142
|
+
- `general.observability.endpoint` (default `''`) — OTLP collector base URL (e.g. `http://localhost:4318`); empty = off, scope: global, restart required (the server; `halo cli` / `halo tui` read all `general.observability.*` keys at start, so evolution runs pick a change up on their next run; cron runs in the server process and follows the server). See [design/observability.md](../design/observability.md)
|
|
143
143
|
- `general.observability.service_name` (default `halo`) — OTel resource `service.name`, scope: global, restart required
|
|
144
144
|
- `general.observability.headers` (default `''`, secret) — extra OTLP request headers, comma-separated `k=v`, scope: global, restart required
|
|
145
145
|
- `general.observability.capture_content` (default `false`) — put prompt/completion/tool text on spans, scope: global, restart required; env `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` (`true` / `false` / `1` / `0`) overrides it
|
|
@@ -15,7 +15,7 @@ Any `?token=…` value in a message is replaced with `?token=<redacted>` before
|
|
|
15
15
|
|
|
16
16
|
## Log locations
|
|
17
17
|
|
|
18
|
-
One file for the whole server: `~/.halo/global/logs/server.log`. A single server process serves many workspaces, so there is no per-workspace log.
|
|
18
|
+
One file for the whole server: `~/.halo/global/logs/server.log`. A single server process serves many workspaces, so there is no per-workspace log. Evolution runs keep their own logs under `~/.halo/global/logs/evo/`. Cron runs write none — a run's session and its run-history row are its record; `~/.halo/global/logs/cron/` only holds files of the older cli-child runner until they age out (30 days). `halo server start -d` additionally captures the process's stdout/stderr in `~/.halo/logs/server.log` — via the systemd user unit's `StandardOutput=append:` on Linux with systemd, otherwise via the built-in supervisor, which adds its `[respawn]` lines — that is the file `halo server logs` tails. A system unit's stdout goes to the journal (`journalctl -u <unit>`).
|
|
19
19
|
|
|
20
20
|
## Log format
|
|
21
21
|
|
|
@@ -449,7 +449,7 @@ A `bearer` remote's token is the settings secret `a2a.secrets.<remote>` (workspa
|
|
|
449
449
|
|
|
450
450
|
**Enabling**: opt-in by the single name `a2a_send` in `agent.yaml`'s `tools:` — it brings `a2a_stop` / `a2a_read` / `a2a_list`; the other three names are not recognised on their own. Available at **every access level** (full, workspace, readonly, any channel) **except the A2A read-only profile** — an inbound A2A session at readonly never gets them, whatever its yaml lists, so a remote's readonly token can't make this server call third parties. The admin tool picker shows one `a2a_send` chip naming the set.
|
|
451
451
|
|
|
452
|
-
**Server only**: in the CLI / TUI
|
|
452
|
+
**Server only**: in the CLI / TUI there is no push receiver; every A2A tool returns `{"code": 1, "error": "a2a is not available in the CLI / TUI — it only runs inside `halo server`, which receives the remote agent's push reports. This is permanent for this runtime, not a temporary outage: do not retry. …"}`. The tools are still listed there when the agent names `a2a_send`.
|
|
453
453
|
|
|
454
454
|
**Retries**: each request has a 30 s timeout. An AgentCore `-32054` "… please retry" answer is retried after 0.5 / 1 / 2 / 4 / 8 s, and a halo AgentCore container's lease-busy answer (`-32603` "workspace is in use by another runtime session …") after 5 / 10 / 15 / 20 s; then the last answer is returned as the error. Stopping the turn ends the wait at once — for the RPC and for the agent-card fetch before it.
|
|
455
455
|
|
|
@@ -547,7 +547,7 @@ Act on the user's **linked machines** — computers running `halo node`, paired
|
|
|
547
547
|
|
|
548
548
|
**Paths**: a machine shares one folder (`--root`; `root` in `node_list`) — relative paths resolve against it, paths that resolve outside it (symlinks included) are refused: `"<p>" is outside this machine's shared folder (<root>) — node tools can only reach paths inside it`. A whole-machine node (`root: null, wholeMachine: true`) has no such check; relative paths resolve against its `base`.
|
|
549
549
|
|
|
550
|
-
**Runtime**: in `halo server`
|
|
550
|
+
**Runtime**: in `halo server` (cron runs included) calls go over the machine's WebSocket. In the standalone CLI / TUI, the evo sandbox and AgentCore mode the tools are listed but every one — `node_list` included — returns `linked machines are not available in this runtime — only \`halo server\` accepts \`halo node\` connections. This is permanent for this runtime, not a temporary outage: do not retry. …`.
|
|
551
551
|
|
|
552
552
|
**Errors** (returned as the tool error text):
|
|
553
553
|
|
|
@@ -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, the
|
|
116
|
+
Internal agents (`internal: true`, e.g. self-evolution agents) have no Test button and never appear in the chat agent selector, the Triggers (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.
|
|
117
117
|
|
|
118
118
|
## Delete
|
|
119
119
|
|
|
@@ -215,9 +215,9 @@ Attach files or images to your message with `@file` and `@image`, or pull a dire
|
|
|
215
215
|
|
|
216
216
|
## OpenTelemetry export
|
|
217
217
|
|
|
218
|
-
`halo cli` and `halo tui` export traces, metrics and logs the same way the server does: when the global `general.observability.endpoint` setting is non-empty, each run registers the OTLP exporters at start and the agent loop emits the same GenAI spans (`invoke_agent` / `chat` / `execute_tool`). With no endpoint nothing is loaded and startup is unaffected. The
|
|
218
|
+
`halo cli` and `halo tui` export traces, metrics and logs the same way the server does: when the global `general.observability.endpoint` setting is non-empty, each run registers the OTLP exporters at start and the agent loop emits the same GenAI spans (`invoke_agent` / `chat` / `execute_tool`). With no endpoint nothing is loaded and startup is unaffected. The evolution pipeline's runs are spawned `halo cli` children, so they show up in the collector too (`session.id` = the internal agent's name); [Cron Tasks](../design/cron.md) run inside the server since 2.2 and export as the server.
|
|
219
219
|
|
|
220
|
-
- **Telling processes apart**: they all share `service.name`; the `halo.process` resource attribute is `server`, `cli` or `tui`,
|
|
220
|
+
- **Telling processes apart**: they all share `service.name`; the `halo.process` resource attribute is `server`, `cli` or `tui`, evolution runs report `cli`, cron runs `server`.
|
|
221
221
|
- **Flushed on exit**: the batched telemetry is sent before the process ends — on normal completion, TUI quit, an error, and on SIGINT / SIGTERM (after the graceful stop, still exiting 130 / 143). The flush is capped at 3 s, so an unreachable collector delays the exit by at most that and never hangs it.
|
|
222
222
|
- `halo agents` and `halo sessions` only list and don't export.
|
|
223
223
|
|
|
@@ -367,7 +367,7 @@ Session prefix: `cli_`. Sessions are persisted to `<workspace>/.halo/sessions/`
|
|
|
367
367
|
|
|
368
368
|
`tui` / `cli` / `agents` / `sessions` refuse to run ("~/.halo/global/ not initialized. Run halo setup first.") until `halo setup` has seeded `~/.halo/global/`. Then the CLI replicates the server's init sequence (`initRuntime()` in `harness.ts`):
|
|
369
369
|
1. `refreshTemplatesIfOutdated()` — the server's template startup check: when `~/.halo/global/.template-version` is behind the bundled `TEMPLATE_VERSION` (typically right after `halo upgrade`), re-seed `~/.halo/global/` before anything reads the models registry; logs `[CLI] Templates outdated (vX → vY), refreshing ~/.halo/global/` to stderr. Runs once per upgrade — afterwards the stamp matches and it's a single file read
|
|
370
|
-
2. `initObservability()` — `tui` / `cli` only (the list-only `agents` / `sessions` skip it): when `general.observability.endpoint` is set, register OTel providers so this run's traces, metrics and logs reach the collector, tagged `halo.process=cli` or `tui`; a no-op otherwise.
|
|
370
|
+
2. `initObservability()` — `tui` / `cli` only (the list-only `agents` / `sessions` skip it): when `general.observability.endpoint` is set, register OTel providers so this run's traces, metrics and logs reach the collector, tagged `halo.process=cli` or `tui`; a no-op otherwise. Evolution runs are spawned `halo cli` children, so they export too. See [OpenTelemetry export](#opentelemetry-export)
|
|
371
371
|
3. `initLogger()` — redirect console to stderr + file logger
|
|
372
372
|
4. `initBwrapCheck()` — probe sandbox availability
|
|
373
373
|
5. `setSandboxHiddenPaths()` — configure sandbox paths
|