@turmind/halo 2.1.5 → 2.2.0

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.
Files changed (55) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/1607-bf227fc5e4db45b7.js +1 -0
  3. package/admin-out/_next/static/chunks/1989-dab5d5c24b8f4c9f.js +1 -0
  4. package/admin-out/_next/static/chunks/5287.7ea08918388fa804.js +1 -0
  5. package/admin-out/_next/static/chunks/app/page-cca81a2ed69100f5.js +1 -0
  6. package/admin-out/_next/static/chunks/{webpack-7f8156dbbdb54868.js → webpack-9532ec7f1fa9a757.js} +1 -1
  7. package/admin-out/_next/static/css/8fc43fce83e5ce5d.css +1 -0
  8. package/admin-out/index.html +1 -1
  9. package/admin-out/index.txt +5 -5
  10. package/bundled-docs/design/a2a.md +5 -5
  11. package/bundled-docs/design/agent.md +1 -1
  12. package/bundled-docs/design/agentcore.md +3 -2
  13. package/bundled-docs/design/architecture.md +3 -3
  14. package/bundled-docs/design/background-dispatch.md +1 -1
  15. package/bundled-docs/design/canvas-extensions.md +5 -5
  16. package/bundled-docs/design/cron.md +77 -53
  17. package/bundled-docs/design/evolution.md +111 -39
  18. package/bundled-docs/design/node.md +30 -13
  19. package/bundled-docs/design/observability.md +12 -12
  20. package/bundled-docs/design/prompt-system.md +1 -1
  21. package/bundled-docs/design/relay.md +4 -4
  22. package/bundled-docs/design/session.md +14 -12
  23. package/bundled-docs/design/slack.md +1 -1
  24. package/bundled-docs/design/storage.md +14 -11
  25. package/bundled-docs/design/telegram.md +1 -1
  26. package/bundled-docs/design/ws.md +4 -4
  27. package/bundled-docs/dev/api.md +16 -14
  28. package/bundled-docs/dev/deploy.md +2 -1
  29. package/bundled-docs/dev/env.md +2 -2
  30. package/bundled-docs/dev/logging.md +1 -1
  31. package/bundled-docs/dev/tools.md +16 -30
  32. package/bundled-docs/guide/cli.md +9 -3
  33. package/bundled-docs/guide/delegation-and-access.md +1 -1
  34. package/bundled-docs/guide/node.md +86 -11
  35. package/bundled-docs/guide/workspace.md +1 -1
  36. package/bundled-docs/requirements/command.md +1 -1
  37. package/bundled-docs/requirements/editor.md +5 -2
  38. package/bundled-docs/requirements/settings.md +2 -2
  39. package/bundled-docs/requirements/terminal.md +1 -1
  40. package/bundled-docs/requirements/workspace.md +1 -1
  41. package/dist/evo-wrapper.js +913 -201
  42. package/dist/index.js +2670 -1786
  43. package/package.json +1 -1
  44. package/templates/agents/__apply_agent__/AGENT.md +10 -6
  45. package/templates/agents/__evo_agent__/AGENT.md +15 -4
  46. package/templates/agents/__score__/AGENT.md +4 -0
  47. package/templates/skills/cron/SKILL.md +37 -10
  48. package/templates/skills/cron/manage-cron.py +90 -15
  49. package/admin-out/_next/static/chunks/1101-d2f818895a3aa32e.js +0 -1
  50. package/admin-out/_next/static/chunks/1989-3aeeefebf77a40f3.js +0 -1
  51. package/admin-out/_next/static/chunks/5058.8514aa6d6a947cce.js +0 -1
  52. package/admin-out/_next/static/chunks/app/page-dac9e957de1eb740.js +0 -1
  53. package/admin-out/_next/static/css/6da86a077e119a6c.css +0 -1
  54. /package/admin-out/_next/static/{nOzfkh9Uo1Cp5TFa-C4gG → Rs9tIYSSITPLxv274DU9f}/_buildManifest.js +0 -0
  55. /package/admin-out/_next/static/{nOzfkh9Uo1Cp5TFa-C4gG → Rs9tIYSSITPLxv274DU9f}/_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 cron `halo cli` child, CLI/TUI — 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** (a cron `halo cli` child, the TUI, a manual `halo cli -s`) 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`, which cron also calls when its child exits) 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.
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 cron `halo cli` 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.
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, a cron `halo cli` child, the 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.
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 cron `halo cli` run and the server) writing one session in the same millisecond, could.
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
 
@@ -107,8 +107,8 @@ interface AgentSession {
107
107
  output: string // all assistant text of the current turn (reset per turn)
108
108
  finalOutput: string // wrap-up text only (stopReason !== 'tool_use') — feeds auto-report / relay report
109
109
  lastActivityAt: string | null // ISO of the turn's latest text / tool_call / tool_result event; null at turn start — surfaced by get_session_output
110
- turnError: string | null // set when the turn died on an unrecoverable error — prefixes the report with an ABORTED marker
111
- turnErrorKind: ModelErrorKind | null // classifyModelError kind of turnError (null exactly when turnError is); 'account' swaps the report's "resume it" hint for "fix the model config first"
110
+ turnError: string | null // set when the turn died on an unrecoverable error or a model refusal stop — prefixes the report with an ABORTED marker
111
+ turnErrorKind: ModelErrorKind | null // classifyModelError kind of turnError, or 'refusal' (null exactly when turnError is); 'account' swaps the report's "resume it" hint for "fix the model config first", 'refusal' for "start a new session with a clean brief"
112
112
  promise: Promise<string> | null // non-null = running
113
113
  abortController: AbortController | null
114
114
  messageQueue: QueuedMessage[] // single unified queue: user→agent AND agent→agent
@@ -156,7 +156,7 @@ Hierarchical encoding: `root_id>child_segment>grandchild_segment`.
156
156
 
157
157
  Both true → sets `stoppedAt` + emits `agent_done` + `querySession`s the result back to the parent. The parent's `querySession` clears its own `stoppedAt`, handles the report, and may trigger its own `tryReportToParent` — bubbling up.
158
158
 
159
- **Abnormal-termination marker**: when the reported turn was killed by an unrecoverable error (retry budget exhausted / account-level failure), the session's `turnError` field holds the error text and the auto-report is prefixed with an explicit `[SUB-AGENT ABORTED: … Error: <text> … Re-dispatch with query_session(…) to resume.]` block. An **account** error (`turnErrorKind === 'account'`: 401/402/403, bad key, no balance) replaces the trailing hint with "this is a model account / credential / balance / permission problem — retrying or re-dispatching will fail the same way; fix the model configuration first (or tell the user), and do not resume it with `query_session` until then"; `RELAY TARGET ABORTED` ([relay.md](relay.md)) and goal mode's `WORKER ABORTED` get the same swap. Without it the parent LLM consumed mid-turn fragments (or a literal "(no output)") as completed reports — the cron-era "sub-agent reported without finishing" incident, root-caused to a Bedrock h2 hang (`TimeoutError: http2 request did not get a response`, now also in the transient-transport retry list). Prefix, don't suppress: skipping the report would leave the parent waiting forever, the partial trace has diagnostic value, and `stoppedAt` is stamped as usual so the child never shows as falsely running. The marker is prepended *before* the truncation cap so it can never be sliced off.
159
+ **Abnormal-termination marker**: when the reported turn was killed by an unrecoverable error (retry budget exhausted / account-level failure), the session's `turnError` field holds the error text and the auto-report is prefixed with an explicit `[SUB-AGENT ABORTED: … Error: <text> … Re-dispatch with query_session(…) to resume.]` block. An **account** error (`turnErrorKind === 'account'`: 401/402/403, bad key, no balance) replaces the trailing hint with "this is a model account / credential / balance / permission problem — retrying or re-dispatching will fail the same way; fix the model configuration first (or tell the user), and do not resume it with `query_session` until then"; `RELAY TARGET ABORTED` ([relay.md](relay.md)) and goal mode's `WORKER ABORTED` get the same swap. A **model refusal** (`stop_reason: "refusal"`, see **Refusal stop** under [Resilient execution loop](#resilient-execution-loop)) also sets `turnError` (`Model declined to respond (<category>): <explanation>`) with `turnErrorKind === 'refusal'`, whose hint reads "this session's conversation history tripped the model's safety classifier — resuming it with `query_session` or re-sending will be declined the same way; do not resume it — start a NEW session with start_session and a clean brief instead, rewording the part that tripped the classifier" (relay / goal / A2A word it for their own context). Without it the parent LLM consumed mid-turn fragments (or a literal "(no output)") as completed reports — the cron-era "sub-agent reported without finishing" incident, root-caused to a Bedrock h2 hang (`TimeoutError: http2 request did not get a response`, now also in the transient-transport retry list). Prefix, don't suppress: skipping the report would leave the parent waiting forever, the partial trace has diagnostic value, and `stoppedAt` is stamped as usual so the child never shows as falsely running. The marker is prepended *before* the truncation cap so it can never be sliced off.
160
160
 
161
161
  The full finally-chain order is `tryReportToParent → deliverGoalRound → deliverRelayReport → releaseSession`. `deliverGoalRound` is a retained legacy hook (goal mode's round delivery): it keys off the row's `goal_session_id` and works as before for any existing goal binding. After the parent-report check, a root whose row carries a `reply_to` back-pointer (it was dispatched from another workspace via `relay_send`) gets its wrap-up appended + sent into the caller session in that workspace (same subtree-quiet gate, `reply_to` cleared before sending — see [relay.md](relay.md)), then the session is released. Each hook decides on its own db column (`parent_id` / `goal_session_id` / `reply_to`), so the three never fire for the same session.
162
162
 
@@ -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 / cron / TUI / evo-wrapper runs end with their terminal and never enter the table. 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.
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); `cron-*` ids — unless the root has a `reply_to`: then a caller waits on it (an AgentCore `/invocations` job run on a cron session id, which the `agentcore-deploy` skill passes; A2A; relay), it isn't a cli-driven cron tree, and it is nudged like a plain root; `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 tree wakes nobody (it waits for its next fire).
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
 
@@ -235,7 +235,7 @@ Stamped **once, before the retry loop**, so every attempt (and the [multimodal 4
235
235
  **`continue_task` — resume after interrupt.** All three interrupt paths share a say/do gap: the interrupted turn unwinds, `drainQueue` runs the new message as a fresh turn, the model answers it and `end_turn`s — it *says* "continuing" but the interrupted work never resumes (worse for sub-agents, whose answer is then auto-reported to the parent as if it were the finished result). The built-in `continue_task` tool (every agent, no params — the one truly unconditional tool: `activate_skill` is gated on `skills`, session tools on `team`; wired in `session-agent-builder`) closes it with a flag pair on `AgentSession`:
236
236
  - **`resumedAfterInterrupt`** is snapshotted from `interruptRequested` at the top of every drained batch (before the per-batch reset) and set `false` for the opening turn. `requestSelfKick` — the tool backend — returns `not_interrupted` and sets nothing unless it is `true`, so a normal turn can't arm the flag: **kicks ≤ interrupts**, no perpetual motion.
237
237
  - **`selfKick`** lives exactly one turn: reset at the top of every drained batch. If that reset drops a `true` (the model armed it, then got interrupted *again* before the kick fired), a one-line `[System] … flag … was reset by this interrupt … call continue_task again` note is appended to that batch's merged input — the model can't see the flag, so it's told.
238
- - **Kick position**: inside `drainQueue`'s while-loop, after `runAgentTurn` and **before** the `batchBoundary` check. When `selfKick` is set, the queue is empty **and `interruptRequested` is false**, it pushes a synthetic `[System] You called continue_task: … resume it now …` user turn (`CONTINUE_TASK_KICK`) and traces it as a `type:'user'` event with `report: true` (not a `system` event — the kick is a raw user turn, so it needs a UI user row or `deleteExchange`'s UI span and `deleteRawTurn`'s raw span diverge; traced at enqueue like every queued message, routed into a sub-agent's log via `taskId`); the existing boundary `complete` then fires naturally so block channels flush the answer as its own message and the kick turn gets its own bubble. The next iteration sees `interruptRequested === false` → `resumedAfterInterrupt = false`, so the kick turn cannot re-arm. If something else landed mid-turn the kick is skipped — the next iteration's reset + note handles it (the newest message might be "stop"; the model decides with that in view, not the loop). The `!interruptRequested` gate covers the other abort path: **esc / `/interrupt` never kicks** — `interruptSession` sets the flag and aborts *without* enqueuing, so the queue check alone would have let an esc'd turn resume itself; esc means "stop what you're doing". Whenever the loop is about to exit on an empty queue the flag is cleared so it never dangles. It is deliberately **not** in `runSession`'s `finally`: `tryReportToParent` / `deliverRelayReport` fire there and must see the *finished* work, so the kick turn has to complete first.
238
+ - **Kick position**: inside `drainQueue`'s while-loop, after `runAgentTurn` and **before** the `batchBoundary` check. When `selfKick` is set, the queue is empty **and `interruptRequested` is false** — and the turn did not end on a deterministic failure (`turnErrorKind` `refusal` / `account`: a kick would fail the same way on the same history / config, one wasted model call; other `turnError`s such as exhausted throttle retries still kick) — it pushes a synthetic `[System] You called continue_task: … resume it now …` user turn (`CONTINUE_TASK_KICK`) and traces it as a `type:'user'` event with `report: true` (not a `system` event — the kick is a raw user turn, so it needs a UI user row or `deleteExchange`'s UI span and `deleteRawTurn`'s raw span diverge; traced at enqueue like every queued message, routed into a sub-agent's log via `taskId`); the existing boundary `complete` then fires naturally so block channels flush the answer as its own message and the kick turn gets its own bubble. The next iteration sees `interruptRequested === false` → `resumedAfterInterrupt = false`, so the kick turn cannot re-arm. If something else landed mid-turn the kick is skipped — the next iteration's reset + note handles it (the newest message might be "stop"; the model decides with that in view, not the loop). The `!interruptRequested` gate covers the other abort path: **esc / `/interrupt` never kicks** — `interruptSession` sets the flag and aborts *without* enqueuing, so the queue check alone would have let an esc'd turn resume itself; esc means "stop what you're doing". Whenever the loop is about to exit on an empty queue the flag is cleared so it never dangles. It is deliberately **not** in `runSession`'s `finally`: `tryReportToParent` / `deliverRelayReport` fire there and must see the *finished* work, so the kick turn has to complete first.
239
239
  - **Interim report at turn end** (`sendInterimReport`, called in `drainQueue` right after `runAgentTurn` returns, before the kick block): the next turn resets `output` / `finalOutput`, and every end-of-run report (`tryReportToParent` / `deliverRelayReport`) reads only the last turn — so a turn that *another turn follows* would never reach whoever asked. Trigger: `(selfKick && !interruptRequested) || messageQueue.length > 0` (a kick is coming, or a message is already queued — the kick itself is skipped then, the interim must not be) **and** the turn was an answer (`selfKick` set, or `finalOutput !== ''` from a natural `end_turn`). An esc after `continue_task` fails the first half → no kick → the run ends → the final report carries the turn, no interim. The per-turn reset stays (get_session_output, the session file and the report cap depend on it); instead the turn's text is snapshotted here and forwarded **only when the batch came from the party this session owes a report** — the pure `interimDoor(parentId, batch)` picks the recipient (`'parent'` / `'relay'` / null): a sub-agent whose batch has a message with `sourceSessionId === parentId` → `querySession(parent, self, "[Interim report · status: still running] … Not the final result.\n\n<answer>")`, **without** stamping `stoppedAt` or emitting `agent_done` (the parent's sibling-status still lists the child as running → "Do NOT wrap up yet"; the real auto-report follows at the end); a root whose batch has a relay-prefixed message → `deliverRelayInterim` (keeps `reply_to`, see [relay.md → Interim report](relay.md#interim-report-continue_task)). Both paths share `deliverInterim(session, 'parent' | 'relay')`. The body is the **whole** turn's `output`, not `finalOutput`: the response carrying the `continue_task` tool_use is `stopReason: tool_use`, so its text is `final: false` and never lands in `finalOutput` — an answer written before the call would otherwise be dropped. Nothing for local user chat (the channel already streamed the answer and a `batchBoundary` complete flushes it), a sibling/other-session message, or an errored turn. Capped at `limits.autoReportMax`.
240
240
  - **Opening-turn interim** (`sendOpeningInterim`, in `runSession`'s `runFn` right after the `hasFirst` turn): the same loss happens when a follow-up is queued while the opening turn runs — the drain's first turn resets the opening wrap-up. Fires when `messageQueue` is non-empty, `finalOutput !== ''` and no `turnError`. A sub-agent's opening message is its parent's dispatch, so it always owes the parent. A root owes its relay caller only when the opening message itself carries the relay prefix (same `interimDoor` as the drain path) **and** a `reply_to` row exists — a pending `reply_to` alone is not enough, or a local user chatting into a root that still owes a relay report would have their answer forwarded to the caller; otherwise nothing.
241
241
  - **Deferred interim** (`sendDeferredInterim`, in `runSession`'s finally right before `tryReportToParent` / `deliverRelayReport`): a session waiting on its own sub-agents is asked by its caller (`relay_send` / the parent's `query_session`) and answers, and the run ends with a child still running — both end-of-run reports skip (subtree not quiet), and the child's later report wakes a new run whose first turn resets `output`, so the answer reached no one. The run's last turn (`RunLastTurn { batch, waitingOnChildren }`, refreshed before the opening turn and every drained batch) sends an interim through `sendInterimReport` when it answered the owed party (`interimDoor`) **while a child was already running at turn start** (`waitingOnChildren` — door first, db lookup only past it, so local chat / child reports / goal rounds / kicks never query; turn *start*, because the dispatch turn starts its child mid-turn and owes the final report, not an interim), `finalOutput !== ''`, no `turnError`, and the subtree is still busy at run end (`listActiveChildren`, the same db gate the two reports read synchronously right after: children running → they skip and this sends; none → they report the turn as final and this doesn't). `reply_to` is kept, so the final report still fires once. Never overlaps the drain path: a turn another turn follows is never the last.
@@ -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 cron `halo cli` child appending to the same file) 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.
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
 
@@ -400,7 +402,7 @@ Frontend network issues don't affect the backend:
400
402
  | **4xx multimodal rejection** (`Multimodal data is corrupted` / `Could not process image`) | Replace all image blocks in history with text placeholders, persist, retry — see [Multimodal 4xx degrade](#multimodal-4xx-degrade) below |
401
403
  | Unrecoverable error | Report to user, stop |
402
404
 
403
- **Refusal stop.** `stop_reason: "refusal"` (Anthropic models on Bedrock invoke + generic anthropic) is an HTTP 200 with `stop_details {category, explanation}` — not an error, so it never enters the retry matrix above. `agent-loop.ts` handles it explicitly: no assistant message is pushed, any partial `tool_use` is **not** executed, the loop ends; `session-manager.ts` emits a `system` event `⚠️ [<agent>] Model declined to respond (<category>): <explanation>` suggesting `/new`. Previously it fell through the `end_turn` path and looked like a silent stall.
405
+ **Refusal stop.** `stop_reason: "refusal"` (Anthropic models on Bedrock invoke + generic anthropic) is an HTTP 200 with `stop_details {category, explanation}` — not an error, so it never enters the retry matrix above. `agent-loop.ts` handles it explicitly: no assistant message is pushed, any partial `tool_use` is **not** executed, the loop ends; `session-manager.ts` emits a `system` event `⚠️ [<agent>] Model declined to respond (<category>): <explanation>` suggesting `/session new`. Previously it fell through the `end_turn` path and looked like a silent stall. It also sets the turn's `turnError` (kind `refusal`), so every abnormal-termination marker sees it — the parent's `[SUB-AGENT ABORTED …]`, `RELAY TARGET ABORTED` / `status: aborted`, goal mode's `WORKER ABORTED`, A2A `FAILED` and an AgentCore invocation's `error` — each with a "start a new session" hint instead of "resume it"; the turn sends no interim report, and a `continue_task` armed in it does not kick (see **Kick position** under [Message queue and drain](#message-queue-and-drain)). Before, a refused sub-agent reported `(no output)` (or the last narration) as a finished result, and the parent kept resuming it into the same refusal.
404
406
 
405
407
  **Classification order: structured signal first, message text last.** The interrupt branch is decided by the attempt's own `AbortController` signal (every interrupt path we own goes through `abortReason()` on it), never by the words `cancelled` / `aborted` in the message — an upstream error body can legitimately read `"The operation was aborted due to …"` (Bedrock), and matching that swallowed a real failure as a user interrupt: silent `break`, no retry, no error event, an empty reply. Likewise the account-level branch trusts `httpStatus` when one was recovered: 401/402/403 is terminal, any *other* status is not, whatever the body says — an OpenAI-compatible 503 whose body reads `"authentication service temporarily unavailable"` used to be classified as a dead key and never retried. The keyword list is only consulted when no status could be extracted. Pinned by `turn-retry-idempotent.test.ts` (seven classification cases).
406
408
 
@@ -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 shows in the cron log, nothing is pushed.
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/ # Per-cron-run cli stdout/stderr (30-day retention)
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 (MANIFEST.json + the overwritten files)
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); cron / evolution runs use a new value on their next run. See [observability.md](observability.md) |
443
- | `general.observability.service_name` | `halo` | OTel resource `service.name`. Restart required (server); cron / evolution: next run |
444
- | `general.observability.headers` | `''` | **Secret.** Extra OTLP request headers, comma-separated `k=v`. Restart required (server); cron / evolution: next run |
445
- | `general.observability.capture_content` | `false` | Put prompt/completion/tool text on spans. Restart required (server); cron / evolution: next run |
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 cron / evolution `halo cli` children don't inherit those settings-derived vars — `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.
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`); `channel-db.ts` / `evo-db.ts` / `runs-db.ts` currently pass an empty list. `runMigrations(sqlite, list)` (`db/migrate.ts`) runs `list[user_version..]` in order, each in its own transaction, stamping `PRAGMA user_version = i + 1` after each
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 → `halo cli --dir`), `cron_jobs.stateless INTEGER NOT NULL DEFAULT 0` (1 = fresh session every run), `cron_runs.session_id TEXT NULL` (the session that run used). `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). `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)
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 shows in the cron log, nothing is pushed.
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.
@@ -119,9 +119,9 @@ Sub-agent text is root-tree noise on this wire: the listener is the root's (sub
119
119
 
120
120
  `chat:thinking` / `chat:stream` / `chat:followup` / `agent:tool_call` / `agent:tool_result` additionally carry `replay: true` (plus `sessionId`) when synthesized by the reattach path (never on live events) — see [Reconnect flow](#reconnect-flow) step 6.
121
121
 
122
- `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 `/new` (see [session.md](session.md#resilient-execution-loop)).
122
+ `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
123
 
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.
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), `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
125
 
126
126
  ### Other Server → Client messages
127
127
 
@@ -141,8 +141,8 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
141
141
  | `chat:stopped` | `chat:stop` / `chat:interrupt` handlers (this client only) | Stop/interrupt acknowledged — `{sessionId}` |
142
142
  | `session:compacted` | compact handler | Compaction complete — `{message?, contextTokens, sessionId}` |
143
143
  | `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 Cron tab re-fetches instead of polling. `cron:job_changed`: `{jobId, kind?}` (`created` / `updated` / `deleted` / `reconciled` / `missed`; a run's finalize sends `lastRunStatus` + `lastRunAt` instead), plus — whenever the job still exists, except `missed` — `workingDir` (workspace-relative, `null` = root), `stateless` (boolean) and `sessionAgentId` (agent of the job's existing session, `null` when stateless / not created / unreadable; the job's agent is locked to it). `cron:run_changed`: `{jobId, runId, status}`. See [cron.md](cron.md). |
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). |
144
+ | `cron:job_changed` / `cron:run_changed` | `cron/runner.ts` + `routes/cron.ts` (broadcast) | Cron job/run state changed — the Cron tab re-fetches instead of polling. `cron:job_changed`: `{jobId, kind?}` (`created` / `updated` / `deleted` / `reconciled` / `missed`; a run's finalize sends `lastRunStatus` + `lastRunAt` instead), plus — whenever the job still exists, except `missed` — `workingDir` (workspace-relative, `null` = root), `stateless` (boolean) and `sessionAgentId` (agent of the job's existing session, `null` when stateless / not created / unreadable; the job's agent is locked to it). `cron:run_changed`: `{jobId, runId, status}`; `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). |
145
+ | `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
146
  | `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
147
  | `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
148
  | `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). |
@@ -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 cli actually runs in: `sessionId`, else the job's own — same id as the job, `cron-<id>` for a custom id without that prefix; older jobs were pinned to their original `cron-cron-…` session by a cron.db migration), `activeFrom` / `activeUntil` (`null` = unbounded), `workingDir` (workspace-relative folder the run focuses on; `null` = the workspace root), `stateless` (boolean; `true` = a fresh session every run), and `sessionAgentId` (the agent of the effective session once it exists — the job's agent is locked to it; `null` for a stateless job, a session not created yet, or a session that can't be read) |
231
- | POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt, schedule, runAt?, timezone?, timeoutSec?, sessionId?, workingDir?, stateless?, targets?, enabled?}`. Exactly one of `schedule` (5-field cron) or `runAt` (epoch ms, future) is required; mutually exclusive. `timeoutSec` must be an integer 60–21600 (else 400); unset = runner default 3600. `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 cli to run in — `[A-Za-z0-9_:-]{1,200}`, no `>` sub-session path (else 400); unset / `null` / `''` = the job's own session (id = the job id). A picked session that already exists keeps its own access level, and its agent must equal `agentId` (agent lock, below); see [design/cron.md](../design/cron.md#execution-runjob) for the shared-session guards. `activeFrom` / `activeUntil` (epoch ms or `null`, recurring only): the schedule fires only inside `[activeFrom, activeUntil)`; with `runAt` → 400; `activeUntil` must be in the future and after `activeFrom` (else 400); `activeFrom` may be in the past. `workingDir` (workspace-relative or absolute; unset / `null` / `''` = the root) must be an existing directory inside `workspacePath` (else 400 `workingDir: …`); stored workspace-relative. `stateless` (boolean, else 400) runs every fire in a fresh session; with `sessionId` → 400 `stateless (new session every run) and sessionId are mutually exclusive`. Agent lock: when the effective session already exists and runs another agent → 400 `session <id> already runs agent <x>; …`; a session db that can't be read → 400 `could not read session <id> from <ws>: …`. Returns `{ok, id}` |
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 `sessionId` (400 `a webhook job runs in its own session …`) and no active window. `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. `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). 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 for one run. Returns `{log}` (string or null when the file is missing / past retention) |
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`, insert a pending `evolution_applies` row that the ticker will pick up. Returns `{ok, applyId}`; 409 when the run isn't `awaiting_review`. |
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 cleared) with the supplied hint (required, else 400) so the wrapper picks it up again. Rejected with 409 when the run is already `running` / `pending`. |
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)
@@ -28,7 +28,7 @@ sudo journalctl -u halo.service --since '2 minutes ago' -n 40 --no-pager
28
28
 
29
29
  **Permission gate:** root npm installs must use `umask 022`, then pass `halo --version` as the actual service user. Secrets/logs can use 077, but it must not leak into public package installation: the 1.5.0-alpha install inherited 077, made root-only package directories and left the ubuntu service failing with 203/EXEC.
30
30
 
31
- **Public release is a separate requested target:** synchronize the five package versions/tag, build and publish npm once, create the GitHub release and attach the Windows exe; verify the exact npm version/dist-tag and GitHub asset. macOS dmg remains the user's step unless requested. See the [publication command reference](#public-release-command-reference) only for the needed commands.
31
+ **Public release is a separate requested target:** synchronize the five package versions/tag, build and publish npm once, create the GitHub release and attach the Windows exe; verify the exact npm version/dist-tag and GitHub assets (the exe, plus the `halo-node-linux-*` binaries and `install.sh` CI attaches). macOS dmg remains the user's step unless requested. See the [publication command reference](#public-release-command-reference) only for the needed commands.
32
32
 
33
33
  The setup, architecture and troubleshooting material below is retained as on-demand reference, not a mandatory routine-deployment checklist.
34
34
 
@@ -323,6 +323,7 @@ Use only for a new public release; the retained details here are not extra prepa
323
323
  5. **npm**: publish from `packages/cli/dist-pub/` **once**, using the intended dist-tag (`--tag alpha` for an alpha, not `latest`). Confirm `npm view @turmind/halo@x.y.z version` and the intended tag via `npm view @turmind/halo dist-tags.alpha` (or `dist-tags.latest` for stable); both must equal the requested version. Registry replication can lag ~2–3 minutes: wait/recheck, never republish. `dist-pub/` is release-only; desktop staging uses `dist-dev/`. Historical reason: a second v1.3.4 publish put `1.3.4-<sha>` on `latest` for ~90s.
324
324
  6. **GitHub**: `gh release create vx.y.z --notes-from-tag` (or the short CHANGELOG summary); use `--prerelease` for an alpha.
325
325
  7. **Windows exe**: `(cd packages/desktop && HALO_STAGE_FULL=1 CI=true pnpm dist:win)`, then `gh release upload vx.y.z "packages/desktop/dist/Halo Setup x.y.z.exe"`. Verify it appears in `gh release view vx.y.z --json assets`; v1.3.0 and v1.3.1 missed this asset. macOS dmg is the user's step unless requested.
326
+ 8. **halo-node binaries**: nothing to build by hand. Publishing the release triggers `.github/workflows/node-go.yml`'s release job (`go vet` + `go test`, then `build.sh`), which uploads `halo-node-linux-{amd64,arm64,armv7}`, `SHA256SUMS` and `install.sh` (with the version stamped in; it isn't listed in `SHA256SUMS`). Wait for it to go green, then check that all five appear in `gh release view vx.y.z --json assets` — the admin's Linux connect command runs this tag's `install.sh`. If the job failed, rerun it with `gh workflow run node-go.yml -f tag=vx.y.z` (`workflow_dispatch`; `--clobber` makes reruns safe). The trigger needs the release created by a person (`gh release create` as yourself), because a release created with `GITHUB_TOKEN` doesn't start other workflows.
326
327
 
327
328
  **npm token gotcha**: `npm publish` on this package needs a granular access token created with **"Bypass 2FA"** checked — scope / permission alone yields `403 Two-factor authentication or granular access token with bypass 2fa enabled is required`. `npm whoami` and `npm token list` succeed with a non-bypass token, so neither is a valid pre-flight; check `GET https://registry.npmjs.org/-/npm/v1/tokens` (with the token as bearer) and look for `"bypass_2fa": true` on the token in use before starting a release.
328
329
 
@@ -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 that cron runs and the evolution wrapper spawn — a dev override |
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 cron and evolution runs pick a change up on their next run). See [design/observability.md](../design/observability.md)
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. Cron runs and evolution runs keep their own logs under `~/.halo/global/logs/{cron,evo}/`. `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>`).
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