@turmind/halo 1.5.0-alpha → 1.5.1-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/app/page-fa5e935dbaa74165.js +1 -0
- package/admin-out/_next/static/css/b8ffb5202b5a6619.css +3 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +3 -3
- package/bundled-docs/design/agent.md +1 -1
- package/bundled-docs/design/canvas-extensions.md +3 -1
- package/bundled-docs/design/express-self.md +9 -7
- package/bundled-docs/design/relay.md +7 -5
- package/bundled-docs/design/session.md +26 -17
- package/bundled-docs/design/storage.md +2 -4
- package/bundled-docs/design/ws.md +4 -4
- package/bundled-docs/dev/deploy.md +39 -9
- package/bundled-docs/dev/desktop-packaging.md +18 -8
- package/bundled-docs/dev/env.md +1 -2
- package/bundled-docs/dev/previews.md +9 -1
- package/bundled-docs/dev/tools.md +2 -2
- package/bundled-docs/guide/chat.md +4 -2
- package/bundled-docs/requirements/chat.md +7 -5
- package/bundled-docs/requirements/editor.md +1 -0
- package/dist/index.js +365 -257
- package/package.json +1 -1
- package/admin-out/_next/static/chunks/app/page-c33392db67853747.js +0 -1
- package/admin-out/_next/static/css/7997e44c08e34b2b.css +0 -3
- /package/admin-out/_next/static/{Wn72NPclwda8BiGd0ChhY → gRsZxPP4kTOmCtkHhJIZB}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{Wn72NPclwda8BiGd0ChhY → gRsZxPP4kTOmCtkHhJIZB}/_ssgManifest.js +0 -0
|
@@ -101,12 +101,14 @@ interface AgentSession {
|
|
|
101
101
|
finalOutput: string // wrap-up text only (stopReason !== 'tool_use') — feeds auto-report / relay report
|
|
102
102
|
lastActivityAt: string | null // ISO of the turn's latest text / tool_call / tool_result event; null at turn start — surfaced by get_session_output
|
|
103
103
|
turnError: string | null // set when the turn died on an unrecoverable error — prefixes the report with an ABORTED marker
|
|
104
|
+
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"
|
|
104
105
|
promise: Promise<string> | null // non-null = running
|
|
105
106
|
abortController: AbortController | null
|
|
106
107
|
messageQueue: QueuedMessage[] // single unified queue: user→agent AND agent→agent
|
|
107
108
|
toolCallLog: Array<{name, inputHash}> // loop detection
|
|
108
109
|
contextConfig: { maxTokens, compressAt }
|
|
109
110
|
isCompacting: boolean
|
|
111
|
+
foldAfterCompact: string | null // queue text a Stop folded while a compact was in flight; clearCompacting lands it on the post-compact history
|
|
110
112
|
interruptRequested: boolean // soft-interrupt flag — abort after the current tool_result
|
|
111
113
|
selfKick: boolean // armed by the built-in continue_task tool; drainQueue turns it into one synthetic resume turn (one-turn lifetime)
|
|
112
114
|
resumedAfterInterrupt: boolean // this turn was started by drainQueue after an interrupt — the only state in which continue_task arms selfKick
|
|
@@ -127,8 +129,8 @@ interface AgentSession {
|
|
|
127
129
|
|
|
128
130
|
Fix (the rule: **agentId is the only identity; nothing that locates or persists may fall back to the display name**):
|
|
129
131
|
- `processSessionEvent` stamps `agentId: session.agentId` on all five bare sub-session events.
|
|
130
|
-
- `
|
|
131
|
-
- `ui-log-builder`'s three `initSubSessionLog` call sites changed `?? agentName` → `?? ''`; an empty id is harmless because `
|
|
132
|
+
- `persistLog` (the sub-session branch) no longer trusts the event-reconstructed `sub.agentId`; it resolves the authoritative id by `taskId` (in-memory session → db row, process-cached) — the same source the root branch uses. This honours the "persistent operations must not depend on in-memory rebuilt state" rule.
|
|
133
|
+
- `ui-log-builder`'s three `initSubSessionLog` call sites changed `?? agentName` → `?? ''`; an empty id is harmless because `persistLog` re-resolves the real id by `taskId`.
|
|
132
134
|
|
|
133
135
|
### Session ID format
|
|
134
136
|
|
|
@@ -147,7 +149,7 @@ Hierarchical encoding: `root_id>child_segment>grandchild_segment`.
|
|
|
147
149
|
|
|
148
150
|
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.
|
|
149
151
|
|
|
150
|
-
**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. 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.
|
|
152
|
+
**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.
|
|
151
153
|
|
|
152
154
|
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.
|
|
153
155
|
|
|
@@ -161,6 +163,8 @@ A sub-session whose process was killed mid-run never got its `stoppedAt` written
|
|
|
161
163
|
|
|
162
164
|
**Workspace-level gate (`.halo/runtime.lock`)**: `server.lock` ownership alone is not sufficient — two servers with different `HALO_HOME` (e.g. prod + dev) each hold their own `server.lock` yet can point at the *same* workspace directory, and one server's boot reconcile would batch-stop the other's actually-live sub-sessions (the incident this gate exists for). So the reconcile additionally requires `claimWorkspaceRuntime(workspaceRoot)` (`agents/workspace-runtime-lock.ts`) — a pid marker at `<workspace>/.halo/runtime.lock` with a liveness probe. Claim fails → skip reconcile and log a warning: **prefer missing a crash-orphan cleanup over stopping another process's live sessions**. `reconcileOrphansOnBoot: true` therefore means "reconcile if the workspace claim succeeds," not "always." Lock protocol details in [storage.md](storage.md#workspace-runtime-lock); known residual: when two servers both actively use one workspace long-term, the non-owner never reconciles — its own crash orphans stay un-cleaned until the owner restarts and takes over. The run-ledger eager boot loop below deals with the *un-opened* case for the same residual: a workspace whose runtime another live process owns is skipped before a SessionManager is even built, so the rows wait for a later boot instead of freezing behind a cached non-owner instance.
|
|
163
165
|
|
|
166
|
+
**Shutdown** (`gracefulShutdown` in `index.ts`): for every loaded workspace, `sm.flushAll()` first lands the UI logs not yet persisted (a dirty root plus every live sub-session log — the pending 500ms debounces would die with the process), then `releaseWorkspaceRuntime` unlinks `.halo/runtime.lock` only when it holds *this* process's pid. A crash / SIGKILL leaves the file behind and takes the "pid dead → next boot takes over" path, so the release is an optimisation (a recycled pid can't read as a live holder), never a correctness dependency. The desktop app's `before-quit` (POSIX) holds the quit until the server process has actually exited (3s hard cap), so `killServer`'s SIGKILL fallback for a wedged server still runs instead of being dropped with the Electron process — otherwise a stuck server keeps the port and `server.lock`.
|
|
167
|
+
|
|
164
168
|
The constructor chain is now `reconcile orphans → sweepActiveGoals (retained legacy) → sweepInterruptedRuns` — all three gated on the same `reconcileOrphansOnBoot` + `.halo/runtime.lock` claim, so a workspace that fails the claim runs none of them.
|
|
165
169
|
|
|
166
170
|
### Run ledger — restart nudge for interrupted roots (`~/.halo/global/runs.db`)
|
|
@@ -207,11 +211,12 @@ A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is
|
|
|
207
211
|
- **`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.
|
|
208
212
|
- **`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.
|
|
209
213
|
- **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.
|
|
210
|
-
- **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
|
|
211
|
-
- **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`.
|
|
212
|
-
- **
|
|
214
|
+
- **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`.
|
|
215
|
+
- **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.
|
|
216
|
+
- **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.
|
|
217
|
+
- **Stop / delete / archive never resurrect**: `stopSession` and `stopUserSession` clear `selfKick` next to `interruptRequested`; `deleteSession` (root + children) and `archiveSession` clear it right after nulling `abortController` and *before* awaiting `session.promise` (the aborted turn's drain still runs inside that promise and would otherwise fire a real kick turn); and `requestSelfKick` returns `no_turn` when `session.abortController === null` — Stop nulls it *before* the abort lands, so a tool callback that completes after a Stop can't re-arm the flag. `enqueueUserMessage` (admin WS) mirrors `sendUserMessage` and does **not** set `interruptRequested` while a compact runs outside a live model loop (manual `/compact`, or the turn-end auto-compact — `abortController` already null) — there is no live turn, and the drain would otherwise snapshot it into `resumedAfterInterrupt` for a turn that interrupted nothing. A mid-turn auto-compact (`beforeCallModel`, `abortController` still set) does sit inside a live turn, so the flag is set and the turn yields after its next tool instead of running to its natural end with the message still queued.
|
|
213
218
|
|
|
214
|
-
**Stop preserves, archive discards.** Both `stopSession` and `stopUserSession` (the user Stop button) fold the **whole** `messageQueue` into `agent.messages` as a user turn (user entries raw, agent entries with their `(from: session X)` prefix) **before** aborting, then `repairConversationMessages` — a stop **parks** the work, it never drops a queued message (mirrors interrupt, which also never loses one). `archiveSession`, by contrast, clears the queue without folding — archiving a subtree is a deliberate discard.
|
|
219
|
+
**Stop preserves, archive discards.** Both `stopSession` and `stopUserSession` (the user Stop button) fold the **whole** `messageQueue` into `agent.messages` as a user turn (user entries raw, agent entries with their `(from: session X)` prefix) **before** aborting, then `repairConversationMessages` — a stop **parks** the work, it never drops a queued message (mirrors interrupt, which also never loses one). While a compact is in flight the fold is parked in `foldAfterCompact` and lands when the compact ends (see [Compaction paths](#compaction-paths)). `archiveSession`, by contrast, clears the queue without folding — archiving a subtree is a deliberate discard.
|
|
215
220
|
|
|
216
221
|
#### Process-group kill on abort
|
|
217
222
|
|
|
@@ -247,7 +252,7 @@ The gate lives at the **tool callback layer** (`agents/session-tools.ts`), not i
|
|
|
247
252
|
|
|
248
253
|
### Viewing a sub-session's live log (`getSessionView`)
|
|
249
254
|
|
|
250
|
-
Because every event reduces into the **root's** UIState (a sub-agent's stream / tool calls land in `rootState.subSessionLogs[subId]`, keyed by the sub's full id), `getSessionView(subId)` must read from there — not from `uiStore.ensureUIState(subId)`, which is keyed by the sub's own id and would only ever see a cold disk-seeded snapshot (and, since the root is self-driven, never re-read disk → a live viewer would freeze on the first frame). So `getSessionView` special-cases a sub-session whose root holds it in `subSessionLogs` (read via `uiStore.getCachedUIState(rootId)`): it snapshots that sub-log directly (in-flight buffers included, fresher than disk). For the root-view path it calls `uiStore.prepareForView(sessionId, selfDriven)`, which evicts a stale cache when the session isn't self-driven so the snapshot reflects disk. Once the sub finishes, `agent_done` deletes the sub-log
|
|
255
|
+
Because every event reduces into the **root's** UIState (a sub-agent's stream / tool calls land in `rootState.subSessionLogs[subId]`, keyed by the sub's full id), `getSessionView(subId)` must read from there — not from `uiStore.ensureUIState(subId)`, which is keyed by the sub's own id and would only ever see a cold disk-seeded snapshot (and, since the root is self-driven, never re-read disk → a live viewer would freeze on the first frame). So `getSessionView` special-cases a sub-session whose root holds it in `subSessionLogs` (read via `uiStore.getCachedUIState(rootId)`): it snapshots that sub-log directly (in-flight buffers included, fresher than disk). For the root-view path it calls `uiStore.prepareForView(sessionId, selfDriven)`, which evicts a stale cache when the session isn't self-driven so the snapshot reflects disk. Once the sub finishes, `agent_done` flushes its log whole to its own file and deletes the in-memory sub-log, so the call falls through to the on-disk file (the next `query_session` re-seeds the sub-log from that file). This is what lets the TUI `/log` viewer refresh a running sub-agent's log in real time.
|
|
251
256
|
|
|
252
257
|
### Idle UIState eviction
|
|
253
258
|
|
|
@@ -255,7 +260,7 @@ Because every event reduces into the **root's** UIState (a sub-agent's stream /
|
|
|
255
260
|
|
|
256
261
|
An idle-sweep candidate is refused eviction while `hasActiveWorkInTree(rootId)` is true — any session in the root's tree (root or `root>…` descendants) with an in-flight `promise` or `isCompacting` exempts the **whole tree**, because a fire-and-forget sub-agent's live stream/tool buffers hang off the *root's* UIState even after the root itself released between turns. This was chosen over the two eviction points that looked simpler but fail on verified facts: dropping in `releaseSession` (turn end) would force an admin-open or mid-conversation channel session to re-read its full file from disk on every next message — hot-path I/O; "drop when no active subscribers" would exempt exactly the leak's dominant source, since a channel account's `InboundBridge` listener subscribes permanently.
|
|
257
262
|
|
|
258
|
-
`dropUIState` flushes a pending debounced persist **before** evicting — otherwise an immediate rehydration would read the shorter on-disk file, and that rehydrated state's next persist would overwrite the orphaned timer's later, complete write. After eviction, the next access (`ensureUIState`) rehydrates from disk exactly as it already does for a cold session — same code path, no special-casing.
|
|
263
|
+
`dropUIState` flushes a pending debounced persist — the root's and its tree's pending sub-session writes — **before** evicting — otherwise an immediate rehydration would read the shorter on-disk file, and that rehydrated state's next persist would overwrite the orphaned timer's later, complete write. After eviction, the next access (`ensureUIState`) rehydrates from disk exactly as it already does for a cold session — same code path, no special-casing.
|
|
259
264
|
|
|
260
265
|
### SQLite metadata
|
|
261
266
|
|
|
@@ -315,7 +320,7 @@ The synthesized `[interrupted]` result above is **model-facing only** (`agent.me
|
|
|
315
320
|
3. **`stopSession`**'s cascade — per descendant, before awaiting the aborted promise (runSession's finally emits `complete`, which flushes and clears the pending buffers this scans).
|
|
316
321
|
4. **`stopUserSession`** (the user Stop button).
|
|
317
322
|
|
|
318
|
-
Idempotent — completed tools (output already set) are never overwritten. Two supporting details: `session-ui-store.flushSubSession()`
|
|
323
|
+
Idempotent — completed tools (output already set) are never overwritten. Two supporting details: `session-ui-store.flushSubSession()` writes a stopped sub-session's log to its own file immediately (the synthetic results only arm the 500ms debounce, and the interrupt is a turn boundary — the marker shouldn't ride a timer); `ui-log-builder.setToolResult` attaches an incoming result to the **first** entry without an output (not the last entry) and never overwrites a completed one — fixes reversed attachment under parallel tool calls and keeps the marker idempotent.
|
|
319
324
|
|
|
320
325
|
### SDK block format handling
|
|
321
326
|
|
|
@@ -346,7 +351,7 @@ Frontend network issues don't affect the backend:
|
|
|
346
351
|
2. **Grace period**: fixed `config.timeout.sessionGrace` (5 min default) — a single `setTimeout`, no auto-extension
|
|
347
352
|
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.
|
|
348
353
|
4. **Reconnect**: a `subscribe` with the same sessionId loads UIState from SessionManager, replays `pendingEvents`, and — if still running — resumes live streaming
|
|
349
|
-
5. **Detach-save dirty gate**: every WS write-back path (disconnect cleanup, grace expiry, session switch/clear, backgroundSaves) 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). The flag is cleared on successful `
|
|
354
|
+
5. **Detach-save dirty gate**: every WS write-back path (disconnect cleanup, grace expiry, session switch/clear, backgroundSaves) 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.
|
|
350
355
|
|
|
351
356
|
## Resilient execution loop
|
|
352
357
|
|
|
@@ -405,9 +410,9 @@ Three entry points with different quality / safety trade-offs:
|
|
|
405
410
|
|
|
406
411
|
| Trigger | Path | Compaction used | Rationale |
|
|
407
412
|
|---|---|---|---|
|
|
408
|
-
| 80% soft threshold (mid-turn auto) | `maybeAutoCompact()` via agent-loop's `beforeCallModel` hook — runs before each model call within a turn | **Self-compact** (`selfCompactSession`) — the agent summarizes its own context, then a tail micro-compact pass clears bulk tool output | The agent already has full context cached (prompt cache hit). No extra model call, no input duplication, no risk of losing tool_result semantics. Firing mid-turn (not just at turn end) stops a single long turn that accumulates many large tool results from blowing the window. |
|
|
413
|
+
| 80% soft threshold (mid-turn auto) | `maybeAutoCompact()` via agent-loop's `beforeCallModel` hook — runs before each model call within a turn | **Self-compact** (`selfCompactSession`) — the agent summarizes its own context, then a tail micro-compact pass clears bulk tool output. **Not interruptible, no wall-clock cap; local fallback** if the summary throws, idle-times-out or comes back empty (see below) | The agent already has full context cached (prompt cache hit). No extra model call, no input duplication, no risk of losing tool_result semantics. Firing mid-turn (not just at turn end) stops a single long turn that accumulates many large tool results from blowing the window. |
|
|
409
414
|
| Overflow mid-loop (`too many input tokens`, `Input is too long`, …) | `runAgentTurn` retry catch → `localCompactMessages` → retry | **Local** — `[role]: <first N chars>` concat, no network call | The model just refused this payload; an LLM round-trip now could stall the recovery path. Local is deterministic and instant; the next end-of-turn can re-summarize via self-compact. |
|
|
410
|
-
| User `/session compact` (web, WeChat) | `commands/compact.ts` / `SessionManager.compactSession` | **Self-compact**,
|
|
415
|
+
| User `/session compact` (web, WeChat) | `commands/compact.ts` / `SessionManager.compactSession` | **Self-compact**, cancellable, **no local fallback** | User explicitly requested it; self-compact reuses the cached context so it's fast. A failure (model error / idle timeout) leaves the context unchanged (`Compaction failed — context unchanged`) and is rethrown to the caller; an empty summary emits `Compaction skipped — no summary produced`. |
|
|
411
416
|
|
|
412
417
|
Self-compact **deep-snapshots the keep-region before running the summarize turn** (`messages.slice(cut).map(structuredClone)`), then injects a summarization instruction into the agent's own stream, captures the response, and rebuilds messages as `[summary + snapshot]`. This reuses the provider's prompt cache (no separate model needed) and preserves full semantic context including tool results.
|
|
413
418
|
|
|
@@ -415,15 +420,19 @@ The pre-run snapshot is load-bearing, not an optimization: `agent.run()` coalesc
|
|
|
415
420
|
|
|
416
421
|
As a final byte-trimming step, self-compact runs **micro-compact** over that snapshot (`microCompactMessages(cleanRecent, 1)`). Each kept-recent message may still carry a large tool result (e.g. a 50 KB `file_read`), so after summarizing it would otherwise re-cross the threshold immediately. Micro keeps only the newest `tool_result`'s content and clears the rest in place, preserving `tool_use_id` pairing so the next API call stays valid — no extra LLM round-trip. Micro-compact is **not** a standalone compaction path; `selfCompactSession` is its only call site — self-compact is the entry point, micro is its tail cleanup.
|
|
417
422
|
|
|
418
|
-
All paths share the same split logic —
|
|
423
|
+
All paths share the same split logic — `compactCut(messages)`, exported from `agents/compact.ts` (single source of truth for SessionManager — `selfCompactSession` + the two feasibility gates below — and `localCompactMessages`): keep the last `keepMessages` turns, advance the cut forward past any orphan `tool_result`-first user message (otherwise the next API call gets `unexpected tool_use_id`). The tail loop only ever moves the cut *up*, so `cut === 0` is exactly the `messages.length <= keepMessages` "nothing to compact" case.
|
|
424
|
+
|
|
425
|
+
**Bounds and Stop.** Self-compact has no wall-clock timeout: a long but still-streaming summary is fine, and a hung call is bounded only by the model-call idle timeout (`timeout.model_request`, re-armed on any data the stream delivers; expiry throws `MODEL_TIMEOUT_ERROR`). An **auto-compact is not interruptible** — `maybeAutoCompact` passes no signal. Stop / Esc (`stopSession`, `interruptSession`, `stopUserSession`, WS `chat:stop` / `chat:interrupt`, `/api/web/stop`) abort only the turn's own controller, which the loop checks once `beforeCallModel` returns, so they take effect after the compact finishes (the stop request itself waits until then). Messages arriving meanwhile queue and run as the next turn once the compact ends; a Stop that folds the queue into history during the compact parks the text in `foldAfterCompact`, and `clearCompacting` lands it on the post-compact history (`foldIntoAgentMessages`). A **manual `/compact`** has no turn, so `cancelCompact` (WS `chat:stop` / `chat:interrupt`, channel `/stop` / `/interrupt` and `POST /api/web/stop` while compacting with no turn running, and `stopUserSession`) cancels it: history rolls back and `Compact cancelled` is emitted.
|
|
426
|
+
|
|
427
|
+
**Auto-compact local fallback.** When the LLM summary throws, idle-times-out, or returns nothing, `localCompactFallback` runs the no-LLM compact instead of leaving the session over the threshold: `localCompactMessages` → tail `microCompactMessages(tail, 1)` → re-estimate tokens. The notice is `Auto-compacted N older messages (local fallback — LLM summary failed: <reason>)` (reason truncated to 80 chars + `…`), emitted with `compactEnd: true` so the WS layer sends `compact:done` and the admin leaves its compacting state. The history is compacted locally rather than left over the threshold, so a failing summarize is not re-run on every later model call.
|
|
419
428
|
|
|
420
|
-
**Notification contract — a "Compacting context…" preflight is only announced when compaction will actually run, and always gets an outcome line.** Both self-compact entry points (`maybeAutoCompact`, manual `compactSession`) check `compactCut() === 0` *before* emitting the preflight and bail silently when there's nothing to compact. After the preflight, every path closes it out: success pairs it with `Auto-compacted N older messages` / `Context compacted: …`; an empty LLM summary (a thinking-heavy model can legally spend its entire response on non-text blocks) emits `Compaction skipped — no summary produced
|
|
429
|
+
**Notification contract — a "Compacting context…" preflight is only announced when compaction will actually run, and always gets an outcome line.** Both self-compact entry points (`maybeAutoCompact`, manual `compactSession`) check `compactCut() === 0` *before* emitting the preflight and bail silently when there's nothing to compact. After the preflight, every path closes it out: success pairs it with `Auto-compacted N older messages` / `Context compacted: …`; an empty LLM summary (a thinking-heavy model can legally spend its entire response on non-text blocks) emits `Compaction skipped — no summary produced` on the manual path; a thrown summarize call emits `Compaction failed — context unchanged` and the manual path rethrows after emitting; the auto path closes both with the `Auto-compacted … (local fallback …)` notice above; a cancelled manual compact emits `Compact cancelled`. Previously the preflight fired first and `selfCompactSession`'s silent `return null` paths left an orphan "Compacting context…" with no outcome — see `memory/2026-08-04-compact-preflight-orphan.md` for the production forensics.
|
|
421
430
|
|
|
422
431
|
**Post-compact token estimate.** Every compact ends with `estimateMessageTokens(messages) + estimateMessageTokens(systemPrompt)` written to `session.lastContextTokens` — the number the 80% gate compares against until the next real `usage` event arrives. It is a heuristic (`chars / 3.5` for mixed CJK/English), and it counts every block type: `text` by length, `tool_use` by `name + JSON(input)`, `tool_result` by its string or nested text blocks, and each image — top-level or nested in a `tool_result` — a flat **1500 tokens** (Anthropic's `w*h/750` rule caps near 1600 at max size; we don't decode dimensions). A text-only count under-read image- and tool-heavy histories and pushed the next auto-compact out until the model itself rejected the payload.
|
|
423
432
|
|
|
424
433
|
**Local compact output is API-legal on its own.** `localCompactMessages` returns `[summary, ...recent]` where `recent` starts past any `tool_result`-first user message, so every kept `tool_result` has its `tool_use` in the kept tail and `repairConversationMessages` is a no-op on the result (also on a second pass over its own output). Pinned by `local-compact-roundtrip.test.ts` — without it the overflow retry would only have "worked" via the later corrupted-conversation repair retry.
|
|
425
434
|
|
|
426
|
-
Config (see `config.compact`): `keepMessages` / `maxSummaryInput` / `maxMessageSlice`
|
|
435
|
+
Config (see `config.compact`): `keepMessages` / `maxSummaryInput` / `maxMessageSlice` — editable in Settings → General → compact.
|
|
427
436
|
|
|
428
437
|
**Self-compact also archives the UI log.** `selfCompactSession` calls `uiStore.archiveOldMessages(session.id)` for **root sessions only** (`!session.parentId`), right before the compaction notice is emitted so the notice lands in the kept exchange rather than inside the archived segment. Compaction is the one path that already means "history shrinks here", which is why archiving hangs off it rather than off a timer or its own sweep — the size threshold below decides whether the call does anything. See [UI-log archiving](#ui-log-archiving).
|
|
429
438
|
|
|
@@ -461,7 +470,7 @@ Accepted edge, documented so it isn't "fixed": `rawMessages` shares the active f
|
|
|
461
470
|
|
|
462
471
|
1. **Segment first, active file second.** `writeArchiveSegment(n)` writes a path nothing references yet; only then is the active file rewritten with the kept exchange. The reverse order could drop messages before their archive exists.
|
|
463
472
|
2. **`archiveCount` in the active file is the commit marker.** A segment with `N > archiveCount` was never committed and no reader may reference it (both the WS anchor and the HTTP route check this). So a crash between the two steps leaves an unreachable orphan plus an active file that still holds every message — nothing lost. The next compact re-derives the same `N` and overwrites the orphan, which is why there's no reconciliation pass.
|
|
464
|
-
3. **Read-back verify, else roll memory back.** `
|
|
473
|
+
3. **Read-back verify, else roll memory back.** `persistLog` swallows IO errors like every session write, so `archiveOldMessages` re-reads `archiveCount` afterwards; if it is `< n` the truncated in-memory `messageLog` is restored from the two halves. Without that, the *next* ordinary persist would write the short log under the old count — that, not the crash, is how messages would actually go missing.
|
|
465
474
|
|
|
466
475
|
Segments are immutable and append-only (`N` from 1, monotonically). Deletion is filesystem-driven: `deleteArchiveSegments()` globs `<seg>.arch.*.json.gz` in the directory rather than trusting `archiveCount` or any in-memory map, so crash-orphaned segments go too. Both session-file delete paths (`deleteSessionFile`, `findAndDeleteSessionFile`) call it.
|
|
467
476
|
|
|
@@ -92,7 +92,7 @@ Protocol (single-machine scope; NFS / cross-host explicitly out):
|
|
|
92
92
|
- **Claim** = `O_EXCL` create with own pid; first claimer owns the runtime.
|
|
93
93
|
- **EEXIST** → read the holder pid and probe with `kill(pid, 0)`. Holder alive (`EPERM` counts as alive — false-dead is the dangerous direction) → return false, caller skips reconcile. Holder is own pid → already owned.
|
|
94
94
|
- **Dead / unreadable holder** → stale takeover via write-tmp + atomic rename (keeps the path existent at all times, so a third comer always sees *some* holder).
|
|
95
|
-
- **
|
|
95
|
+
- **Release on graceful exit**: `gracefulShutdown` calls `releaseWorkspaceRuntime` per workspace, which unlinks the file only when it holds *our own* pid (a file another live process owns or took over is left alone). A crash / SIGKILL leaves the file and converges on the "pid dead → next boot takes over" path, so correctness never depends on the exit hook having run — the release only keeps a reboot-recycled pid from reading as a live holder.
|
|
96
96
|
|
|
97
97
|
Bias is deliberate: pid reuse can read a stale pid as "alive," which only costs one missed cleanup round; a false-dead could stop another process's live sessions. Full race analysis in the file's header comment.
|
|
98
98
|
|
|
@@ -209,7 +209,7 @@ type ContentBlockEntry =
|
|
|
209
209
|
|
|
210
210
|
`turnId` uniquely identifies each LLM API call. Content blocks and the corresponding usage message in the same turn share the same turnId.
|
|
211
211
|
|
|
212
|
-
The `usage` event rotates `turnId` on both root and sub-session turn state without flushing the assistant message: the agent loop yields `tool_call`s before `usage` and runs the tools after, so at usage time every tool call of that model call is still pending — a flush there would split thinking/text from the tool_calls into two assistant messages (the pre-fix sub-session behavior). One assistant message therefore spans all model calls of a turn, each call's blocks tagged with its own turnId; it reaches disk at `complete` / `agent_done`, the next `user` or `system` event, or the interrupt flush.
|
|
212
|
+
The `usage` event rotates `turnId` on both root and sub-session turn state without flushing the assistant message: the agent loop yields `tool_call`s before `usage` and runs the tools after, so at usage time every tool call of that model call is still pending — a flush there would split thinking/text from the tool_calls into two assistant messages (the pre-fix sub-session behavior). One assistant message therefore spans all model calls of a turn, each call's blocks tagged with its own turnId; it reaches disk at `complete` / `agent_done`, the next `user` or `system` event, or the interrupt flush. Root and sub-session logs persist by one rule: `persistLog` overwrites the session's own file whole from its UI log, and every save carries the in-flight turn as a temporary assistant message (`createSaveSnapshot`). A sub-session log starts from its own file (`loadSubHistory`), so it always holds the full history; writes are debounced 500ms per session id (tool_call / tool_result / usage / context / user / system / error) and flushed at turn end (root `complete`, sub `agent_done`) and on interrupt.
|
|
213
213
|
|
|
214
214
|
### Render rules
|
|
215
215
|
|
|
@@ -334,7 +334,6 @@ general: # built-in declarer (the server itself
|
|
|
334
334
|
keep_messages: 5
|
|
335
335
|
max_summary_input: 15000
|
|
336
336
|
max_message_slice: 800
|
|
337
|
-
summarize_timeout_sec: 300
|
|
338
337
|
sandbox:
|
|
339
338
|
hidden_dirs: "~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh"
|
|
340
339
|
hidden_files: "~/.npmrc,~/.bash_history,~/.gitconfig,~/.git-credentials,~/.netrc,~/.halo/global/evo.db,~/.halo/global/evo.db-wal,~/.halo/global/evo.db-shm,~/.halo/global/cron.db,~/.halo/global/cron.db-wal,~/.halo/global/cron.db-shm,~/.halo/global/runs.db,~/.halo/global/runs.db-wal,~/.halo/global/runs.db-shm"
|
|
@@ -384,7 +383,6 @@ A value of the form `<<ENV_NAME>>` is replaced at read time with `process.env.EN
|
|
|
384
383
|
| `general.compact.keep_messages` | 5 | Recent messages kept intact during compaction |
|
|
385
384
|
| `general.compact.max_summary_input` | 15000 | Local truncation fallback total char cap |
|
|
386
385
|
| `general.compact.max_message_slice` | 800 | Local truncation per-message char cap |
|
|
387
|
-
| `general.compact.summarize_timeout_sec` | 300 | LLM summary timeout |
|
|
388
386
|
| `general.limits.shell_output_bytes` | 5242880 | Max bytes captured from one `shell_exec` (stdout+stderr); excess truncated with a `[truncated]` marker |
|
|
389
387
|
| `general.limits.web_fetch_bytes` | 51200 | Max bytes downloaded by one `web_fetch` |
|
|
390
388
|
| `general.limits.grep_default_matches` | 50 | Default `grep` match cap when no explicit `max` is passed |
|
|
@@ -62,8 +62,8 @@ Both directions are typed in `packages/core/src/protocol/ws-frames.ts` (`WsClien
|
|
|
62
62
|
| `subscribe` | Subscribe to a session (load history, re-attach detached) |
|
|
63
63
|
| `chat` | Send a user message (queued when the agent is busy); acked per `clientMsgId` — see [Chat delivery](#chat-delivery-ack--resend--dedup) |
|
|
64
64
|
| `__ping__` | Application-level liveness probe; server replies `__pong__` |
|
|
65
|
-
| `chat:stop` | Hard-abort the current generation (ends the turn, no re-run) → `stopUserSession` |
|
|
66
|
-
| `chat:interrupt` | Interrupt the in-flight turn now (aborts a command mid-run); the server then folds any queued messages into one follow-up turn → `interruptSession`. Admin chat esc maps to this.
|
|
65
|
+
| `chat:stop` | Hard-abort the current generation (ends the turn, no re-run) → `stopUserSession`. During a manual `/compact` (compacting, no turn running) it cancels the compact instead (`cancelCompact`, history rolls back). During an auto-compact — which runs inside a turn and is not cancellable — the abort takes effect once the compact finishes. |
|
|
66
|
+
| `chat:interrupt` | Interrupt the in-flight turn now (aborts a command mid-run); the server then folds any queued messages into one follow-up turn → `interruptSession`. Admin chat esc maps to this. Same compact semantics as `chat:stop`: a manual `/compact` with no turn running is cancelled; an auto-compact finishes first, then the interrupt lands. |
|
|
67
67
|
| `session:clear` | Non-destructive /session new: save the current, release its listener, create fresh (handled inline) — see [Command dispatch](#command-dispatch) |
|
|
68
68
|
| `session:delete` | Delete session files + cascade-delete descendants in SQLite (handled inline) |
|
|
69
69
|
| `exchange:delete` | Delete one exchange (a user turn + its responses): **soft-delete** in the UI log (`deleted: true` markers, kept visible/greyed) + **physical-delete** the whole turn from `rawMessages` (LLM context) → `deleteExchange`. Fields: `userOrdinal` (0-based index among *main-conversation* user turns, i.e. excluding `taskId` sub-agent messages — matches the admin's `isMainConversationMessage` count), `archiveCount` (the archived-segment count the client's view was opened against — its archive anchor, from the `state:snapshot` it got on subscribe), optional `sessionId` / `projectId`. Rejected (→ `error`) while the session is running or compacting. The server also compares `archiveCount` against the on-disk header count (`readArchiveCount`); only a **mismatch** is refused, with `code: 'archived'` ("… archived history since it was opened — reopen it to delete individual turns") — a session with archives can still be deleted from as long as the anchor matches. See [session.md](session.md#exchange-deletion-soft-ui--hard-raw). |
|
|
@@ -132,7 +132,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
|
|
|
132
132
|
| `session:deleted` | session:delete handler (this client only) | Session delete complete — `{sessionId}` |
|
|
133
133
|
| `chat:stopped` | `chat:stop` / `chat:interrupt` handlers (this client only) | Stop/interrupt acknowledged — `{sessionId}` |
|
|
134
134
|
| `session:compacted` | compact handler | Compaction complete |
|
|
135
|
-
| `compact:started` / `compact:summarizing` / `compact:done` | compact handler | Compaction progress |
|
|
135
|
+
| `compact:started` / `compact:summarizing` / `compact:done` | compact handler; `ws/event-processor.ts` | Compaction progress. 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`). |
|
|
136
136
|
| `cron:job_changed` / `cron:run_changed` | `cron/runner.ts` + `routes/cron.ts` (broadcast) | Cron job/run state changed — the Cron tab re-fetches instead of polling. See [cron.md](cron.md). |
|
|
137
137
|
| `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). |
|
|
138
138
|
| `extension:changed` | `extensions/watcher.ts` `rescanAndBroadcast` (broadcast to all clients) | `~/.halo/global/extensions/` changed (install / upgrade / uninstall, from the admin upload, the `extension` skill, or a manual `mv`/`rm`) — carries the **full** `ExtensionsSnapshot` (`{extensions, errors}`), not a diff, and only fires when the snapshot key differs from the last one. The admin replaces its extension list wholesale: the preview registry re-resolves open tabs (an upgraded extension remounts, or shows a banner if the tab is dirty; an uninstalled one falls back to the "no built-in preview" page) and the Settings → Extensions panel re-renders. Global, no workspace marker — extensions are server-wide. See [canvas-extensions.md](canvas-extensions.md). |
|
|
@@ -222,7 +222,7 @@ Client reconnects and sends `subscribe`:
|
|
|
222
222
|
5. Re-attach the event listener — live streaming continues seamlessly
|
|
223
223
|
6. If the session is still running, synthesizes the in-progress turn as an **authoritative replay**: every synthesized message carries `replay: true` — a `chat:followup`, then the turn's `turnContentBlocks` in order (`chat:thinking` / `chat:stream` / `agent:tool_call` + `agent:tool_result` if completed), each with the session's real `agentName` and the block's real `turnId` so usage badges / turn grouping survive the reconnect. The running-session snapshot in step 3 sends the settled `messageLog` only (no `createSaveSnapshot` temp in-flight message — the replay carries that turn instead).
|
|
224
224
|
|
|
225
|
-
**Client replace semantics** (`chat-handlers.ts` + `state-handlers.ts`): the client stashes every reattach snapshot (even when the in-flight guard skips the visible replace). On a replay-flagged `chat:followup` it **discards its locally-held in-flight turn** — resetting `messages` to the stashed settled log — and rebuilds the turn from the replayed events. Without the replace, a client that had kept its streamed text through the drop (the snapshot guard assumes it did) got the same text appended again by the replay; `toolUseId` dedup only covered tool rows. Live (non-replay) followups keep append behavior. Replayed tool entries still carry `toolUseId` (dedup + `tool_result` pairing by id, with a first-pending positional fallback for entries without one — old persisted sessions).
|
|
225
|
+
**Client replace semantics** (`chat-handlers.ts` + `state-handlers.ts`): the client stashes every reattach snapshot (even when the in-flight guard skips the visible replace). On a replay-flagged `chat:followup` it **discards its locally-held in-flight turn** — resetting `messages` to the stashed settled log — and rebuilds the turn from the replayed events. Without the replace, a client that had kept its streamed text through the drop (the snapshot guard assumes it did) got the same text appended again by the replay; `toolUseId` dedup only covered tool rows. Live (non-replay) followups keep append behavior. `setMessages` also moves the store's `roundStart` cursor to the end of the loaded log, so `<<<SHOW>>>` / `<<<CAPTURE>>>` markers in loaded history (snapshot or the settled log of a replay) never fire — only bubbles that arrive after it and settle at `chat:complete` do. Replayed tool entries still carry `toolUseId` (dedup + `tool_result` pairing by id, with a first-pending positional fallback for entries without one — old persisted sessions).
|
|
226
226
|
|
|
227
227
|
### Double-subscribe guard
|
|
228
228
|
|
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Deployment
|
|
2
2
|
|
|
3
|
+
## Local maintenance — default short path
|
|
4
|
+
|
|
5
|
+
For this personal-use workspace: **version/tag → required build → install → restart → basic version/health/startup-log checks**. For an already published version, just **install → restart → checks**; don't rebuild or republish it. Reuse applicable passed tests rather than rerunning a full suite.
|
|
6
|
+
|
|
7
|
+
No default backups, rollback preparation/scripts/instructions, or extra per-release plans/checklists/report files. A short CHANGELOG summary and a brief result are enough. Explicit authorization for the named service is sufficient; don't ask for another GO. Diagnose/fix failures within the authorized scope instead of automatic rollback.
|
|
8
|
+
|
|
9
|
+
Confirm the service/user/HOME/port in `dev-environment.local.md`. If the agent is inside the service being restarted, carry the install/restart/basic checks in one independent systemd task; from outside it (e.g. dev → prod), operate directly. No deployment framework is needed.
|
|
10
|
+
|
|
11
|
+
For this host's `/usr` installation and `ubuntu` service user, after authorization:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
(
|
|
15
|
+
set -e
|
|
16
|
+
version='x.y.z' # exact requested version
|
|
17
|
+
sudo sh -c 'umask 022; npm install -g --prefix /usr --no-audit --no-fund "@turmind/halo@$1"' sh "$version"
|
|
18
|
+
installed=$(sudo -u ubuntu env HOME=/home/ubuntu /usr/bin/halo --version 2>&1)
|
|
19
|
+
test "$installed" = "halo $version" # CLI writes to stderr; mismatch or launch failure stops before restart
|
|
20
|
+
sudo systemctl restart halo.service
|
|
21
|
+
curl --fail --retry 10 --retry-connrefused --retry-delay 1 --max-time 3 http://127.0.0.1:9527/api/health # status ok, requested version
|
|
22
|
+
sudo journalctl -u halo.service --since '2 minutes ago' -n 40 --no-pager
|
|
23
|
+
)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**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.
|
|
27
|
+
|
|
28
|
+
**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.
|
|
29
|
+
|
|
30
|
+
The setup, architecture and troubleshooting material below is retained as on-demand reference, not a mandatory routine-deployment checklist.
|
|
31
|
+
|
|
3
32
|
## Architecture overview
|
|
4
33
|
|
|
5
34
|
Halo only needs **one Node process** (Hono on port 9527 by default). API, WebSocket, and static frontend live in the same process.
|
|
@@ -196,16 +225,17 @@ This installs the `halo` binary on `$PATH`. Subcommands available:
|
|
|
196
225
|
1. `halo upgrade` — bumps the on-disk npm package
|
|
197
226
|
2. `halo server restart` — server's startup check sees `~/.halo/global/.template-version` is behind the new bundled `TEMPLATE_VERSION`, runs `ensureHaloHome` automatically, then starts. Refreshes `docs/`, built-in agents, built-in skills, system prompts, and the model registry. User-owned files (USER.md, custom agents/skills, INSTRUCTIONS.md overrides) are left alone. See `init.ts` for the per-category overwrite policy.
|
|
198
227
|
|
|
199
|
-
###
|
|
228
|
+
### Public release command reference
|
|
229
|
+
|
|
230
|
+
Use only for a new public release; the retained details here are not extra preparation for installing an existing version.
|
|
200
231
|
|
|
201
|
-
1. **
|
|
202
|
-
2. **
|
|
203
|
-
3. **
|
|
204
|
-
4.
|
|
205
|
-
5.
|
|
206
|
-
6. **GitHub
|
|
207
|
-
7. **Windows exe
|
|
208
|
-
8. **Post-check** (whoever ran the release does NOT get to skip this): `gh release view vx.y.z --json assets` must list the exe, and `npm view @turmind/halo version` must print `x.y.z`. A release without the exe attached is not done.
|
|
232
|
+
1. **Versions**: align the five workspace `package.json` files (`packages/{cli,server,core,admin,desktop}/package.json`) with the tag — the root `package.json` has no version field.
|
|
233
|
+
2. **CHANGELOG**: a short version/date summary is enough; keep the existing `[Unreleased]` and compare-link convention without writing a separate release report.
|
|
234
|
+
3. **Templates**: bump `TEMPLATE_VERSION` in `packages/server/src/init.ts` when `templates/` changed. `build-bundle.mjs` enforces this against the previous release tag; an unchanged number stops bundling.
|
|
235
|
+
4. Commit/tag `vx.y.z`/push within authorization, then build the required artifacts: admin via `pnpm --filter @turmind/halo-admin build` (verify `packages/admin/out/monaco/vs/loader.js`), CLI via `HALO_RELEASE=1 pnpm --filter @turmind/halo-cli bundle`.
|
|
236
|
+
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.
|
|
237
|
+
6. **GitHub**: `gh release create vx.y.z --notes-from-tag` (or the short CHANGELOG summary); use `--prerelease` for an alpha.
|
|
238
|
+
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.
|
|
209
239
|
|
|
210
240
|
**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.
|
|
211
241
|
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
# Desktop Packaging (Electron / DMG)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## Short path
|
|
4
|
+
|
|
5
|
+
Read this block and the command for the requested target; the historical Gotchas and detailed steps below are on-demand reference, not required cover-to-cover reading.
|
|
6
|
+
|
|
7
|
+
1. Keep core/server/admin/cli/desktop versions aligned with the tag; template changes need a `TEMPLATE_VERSION` bump in `packages/server/src/init.ts`. A short CHANGELOG summary is enough.
|
|
8
|
+
2. Build the target's required upstream artifacts (core → server → admin), reusing valid results for unchanged inputs. Admin uses `pnpm --filter @turmind/halo-admin build`, never bare `next build`; check `packages/admin/out/monaco/vs/loader.js` before bundling. Don't repeat passed applicable tests.
|
|
9
|
+
3. Windows on Linux: `(cd packages/desktop && HALO_STAGE_FULL=1 CI=true pnpm dist:win)`. macOS dmg is the user's step unless requested; use the Mac command below when needed.
|
|
10
|
+
4. For a public release, npm publication and the GitHub release must also be done, with the Windows exe uploaded and visible in `gh release view v<ver> --json assets`. For packaging-only work, report the artifact path; don't silently expand into publication or deployment.
|
|
11
|
+
|
|
12
|
+
No default backups, rollback preparation/scripts/instructions, or extra release plans/checklists/report files. Server installation/restart uses the [short maintenance path](deploy.md#local-maintenance--default-short-path), including root npm `umask 022` and a service-user `halo --version` check.
|
|
13
|
+
|
|
14
|
+
How to build the desktop app. Two artifact formats are supported: **Windows x64**
|
|
4
15
|
(`Halo Setup <ver>.exe`, built on a Linux host) and **macOS arm64**
|
|
5
16
|
(`Halo-<ver>-arm64.dmg`, built on Apple Silicon) — see the target matrix
|
|
6
17
|
below. The desktop build is a thin Electron shell (`packages/desktop`) that
|
|
@@ -11,11 +22,10 @@ static export, then spawns the server as a child process and points a
|
|
|
11
22
|
See [design](../design/) for the server itself; this doc is only about the
|
|
12
23
|
packaging pipeline.
|
|
13
24
|
|
|
14
|
-
>
|
|
15
|
-
>
|
|
16
|
-
>
|
|
17
|
-
>
|
|
18
|
-
> first; absence just means none were recorded on this machine.
|
|
25
|
+
> `desktop-packaging.local.md` (gitignored, per-host), if present, carries
|
|
26
|
+
> machine-specific setup such as Linux wine/32-bit dependencies and artifact
|
|
27
|
+
> distribution. Consult the relevant target notes when needed; absence means
|
|
28
|
+
> none were recorded on this machine.
|
|
19
29
|
|
|
20
30
|
## Architecture
|
|
21
31
|
|
|
@@ -74,7 +84,7 @@ Output: `packages/desktop/dist/Halo-0.1.5-arm64.dmg` (~143 MB).
|
|
|
74
84
|
|
|
75
85
|
## Release target matrix (current policy)
|
|
76
86
|
|
|
77
|
-
Two
|
|
87
|
+
Two supported targets: the release agent ships Windows by default; the user builds/uploads macOS separately unless explicitly delegated. Each uses its native-ish host:
|
|
78
88
|
|
|
79
89
|
| Target | Host we build on | Command | Notes |
|
|
80
90
|
|--------|------------------|---------|-------|
|
|
@@ -112,7 +122,7 @@ Verified on the v0.1.9 build (Linux x86_64 host → win-x64):
|
|
|
112
122
|
skipped` (no Windows code-sign cert). Neither fails the build.
|
|
113
123
|
|
|
114
124
|
> A `desktop-packaging.local.md` (gitignored, per-host) may carry extra
|
|
115
|
-
> machine-specific setup —
|
|
125
|
+
> machine-specific setup — consult the relevant target section when needed.
|
|
116
126
|
|
|
117
127
|
## Faster rebuilds
|
|
118
128
|
|
package/bundled-docs/dev/env.md
CHANGED
|
@@ -103,7 +103,7 @@ Source: `packages/server/src/config.ts`
|
|
|
103
103
|
| `HALO_MAX_CONTEXT_TOKENS` | `200000` | — | Model max context |
|
|
104
104
|
| `HALO_SHELL_TIMEOUT` | `120000` | `config.yaml timeout.shell_exec` | Shell command timeout (ms) |
|
|
105
105
|
| `HALO_WEB_FETCH_TIMEOUT` | `10000` | `config.yaml timeout.web_fetch` | web_fetch timeout |
|
|
106
|
-
| `HALO_MODEL_TIMEOUT` | `
|
|
106
|
+
| `HALO_MODEL_TIMEOUT` | `600000` | `config.yaml timeout.model_request` | Per model-call idle timeout (ms, 10 min): every provider streams (Bedrock, the Anthropic-Messages providers anthropic / mimo / minimax / qwen, the OpenAI-family openai / deepseek / kimi / zhipu / doubao / hunyuan, and Mantle aws-bedrock-mantle / aws-bedrock-openai) and re-arms it on any data the stream delivers — text / thinking deltas, pings, tool-argument fragments, empty reasoning chunks, SSE comment frames — so it fires only when the model goes silent for this long, not on a long but live answer. It is also the only bound on a compact's LLM summary (no separate compact timeout). A bare fetch has no default timeout, so a half-open connection would hang the request forever; on expiry the call aborts and the agent loop retries with backoff. |
|
|
107
107
|
| `HALO_SESSION_GRACE` | `300000` | `config.yaml timeout.session_grace` | WS-disconnect session grace |
|
|
108
108
|
| `HALO_TERMINAL_GRACE` | `300000` | `config.yaml timeout.terminal_grace` | Terminal disconnect grace |
|
|
109
109
|
| `HALO_MAX_CACHED_SESSIONS` | `50` | — | In-memory session cache |
|
|
@@ -122,7 +122,6 @@ settings.yaml only (no env override):
|
|
|
122
122
|
- `general.compact.keep_messages` (default 5) — recent messages to keep uncompacted
|
|
123
123
|
- `general.compact.max_summary_input` (default 15000) — local compaction fallback input cap
|
|
124
124
|
- `general.compact.max_message_slice` (default 800) — local compaction per-message cap
|
|
125
|
-
- `general.compact.summarize_timeout_sec` (default 300) — self-compact timeout
|
|
126
125
|
- `general.server.trust_proxy` (default `false`) — trust `x-forwarded-for` for client IP resolution, scope: global (enable only behind a reverse proxy you control)
|
|
127
126
|
- `general.sandbox.hidden_dirs` (default `~/.halo/secrets,~/.aws,~/.ssh,~/.gnupg,~/.docker,~/.config/gh,~/.halo/global/internal-sessions,~/.halo/global/logs`) — hidden from workspace/readonly sessions (bwrap tmpfs overlays on Linux, Seatbelt deny on macOS), scope: global
|
|
128
127
|
- `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
|
|
@@ -11,7 +11,7 @@ previews/
|
|
|
11
11
|
├── FilePreview.tsx Public entry. Looks up plugin by extension,
|
|
12
12
|
│ renders <Suspense><Component/></Suspense>.
|
|
13
13
|
├── types.ts PreviewPlugin, PreviewProps
|
|
14
|
-
├── registry.ts register() / getPlugin() / registeredExtensions() / isHeavyPreview()
|
|
14
|
+
├── registry.ts register() / getPlugin() / registeredExtensions() / isHeavyPreview() / loadExtensions()
|
|
15
15
|
├── ui/
|
|
16
16
|
│ ├── preview-shell.tsx Standard header (filename + Open-as-Text + Download + extraToolbar)
|
|
17
17
|
│ ├── use-preview-fetch.ts Hook: fetch + AbortController + parse, returns {data, error, loading}
|
|
@@ -123,6 +123,14 @@ Done. The Canvas panel will:
|
|
|
123
123
|
- Treat `.foo` / `.foobar` as non-text (routes to preview instead of Monaco)
|
|
124
124
|
- Mount `FooPreview` inside an MRU cache (up to 5 concurrent plugins cached)
|
|
125
125
|
|
|
126
|
+
### Routing API: `canPreview()` vs `loadExtensions()`
|
|
127
|
+
|
|
128
|
+
`canPreview(ext)` is a live read of the registry (built-ins + installed extensions), so it is only as good as the extension list loaded so far. `loadExtensions()` (re-exported from `FilePreview.tsx`) is the page's single shared initial `GET /extensions` — `workspace-layout.tsx` starts it in parallel with the workspace resolve.
|
|
129
|
+
|
|
130
|
+
- **One-shot routing decisions** (`isBinaryExtension` in `editor-panel.tsx`: click-open, tab restore after reload, open-to-side) must `await loadExtensions()` before concluding "text", because an extension may claim an extension-only type (`.glb`) or a text suffix (`.json`). A preview verdict needs no wait — extensions only add viewers ahead of text, and `FilePreview` re-picks the viewer on a registry version bump.
|
|
131
|
+
- The wait is capped at `INITIAL_LOAD_WAIT_MS` (3 s, `registry.ts`), counted from the first call; on timeout callers proceed as if nothing were installed. The request isn't cancelled — a late result still lands via `setExtensions`.
|
|
132
|
+
- It never rejects and is never re-issued: a failed fetch leaves the extension layer empty without delaying later opens. The WS-reconnect re-fetch and `extension:changed` frames fill it in.
|
|
133
|
+
|
|
126
134
|
## PreviewShell — the standard header
|
|
127
135
|
|
|
128
136
|
Every plugin should wrap its content in `<PreviewShell>` for consistency. The shell gives you:
|
|
@@ -379,9 +379,9 @@ Returns `{ "code": 0, "workspace": "<realpath>", "session_id": "…", "state": "
|
|
|
379
379
|
<target's finalOutput || output>
|
|
380
380
|
```
|
|
381
381
|
|
|
382
|
-
Body capped at `limits.autoReportMax` (head-kept) with a `[Report truncated: N chars total. Use relay_read("<ws>", "<id>") for the full text.]` marker. A turn killed by an unrecoverable error carries `· status: aborted]` in the header and is prefixed `[RELAY TARGET ABORTED: … Error: <text> … Re-send with relay_send to let it resume.]` **before** the cap so it can't be sliced off. `reply_to` is cleared **before** sending: one dispatch → one report, a failed delivery can't double-fire on the next turn end, and a user chatting directly in the department workspace afterwards never pings the secretary.
|
|
382
|
+
Body capped at `limits.autoReportMax` (head-kept) with a `[Report truncated: N chars total. Use relay_read("<ws>", "<id>") for the full text.]` marker. A turn killed by an unrecoverable error carries `· status: aborted]` in the header and is prefixed `[RELAY TARGET ABORTED: … Error: <text> … Re-send with relay_send to let it resume.]` **before** the cap so it can't be sliced off. When the error is an account problem (401/402/403, bad key, no balance), the closing hint instead says it is a model account / credential / balance / permission problem in the target workspace: retrying or re-sending fails the same way, so fix its model configuration (or tell the user to) and do not resume it with `relay_send` until then. `reply_to` is cleared **before** sending: one dispatch → one report, a failed delivery can't double-fire on the next turn end, and a user chatting directly in the department workspace afterwards never pings the secretary.
|
|
383
383
|
|
|
384
|
-
**Interim report**: if a busy target answers a follow-up `relay_send` in a turn that another turn follows — it called `continue_task` to resume what it was doing, or a further relay message was already queued when the answer ended (also on the opening turn) — the answer is delivered right away as
|
|
384
|
+
**Interim report**: if a busy target answers a follow-up `relay_send` in a turn that another turn follows — it called `continue_task` to resume what it was doing, or a further relay message was already queued when the answer ended (also on the opening turn), or the target was waiting on its own sub-agents when asked and the run ended on the answer with a sub-agent still running — the answer is delivered right away as
|
|
385
385
|
|
|
386
386
|
```
|
|
387
387
|
[Relay interim report · workspace <ws> · session <id> · status: still running] This is an interim reply — the session is still working; its final [Relay report] follows when done. Do not treat this as the result.
|
|
@@ -84,7 +84,7 @@ If you're chatting from WeChat, the same shared commands are available (routed t
|
|
|
84
84
|
| `/qr [level]` | Generate an invite QR for a new bot account (full 权限 bot 专用) |
|
|
85
85
|
| `/help` | Show help |
|
|
86
86
|
|
|
87
|
-
If the session is currently compacting or busy when your message arrives, you'll see a hint — ("⏳ integrating context…" / "🔄 queued…") — and the message is queued either way, processed as soon as the compact / current turn finishes.
|
|
87
|
+
If the session is currently compacting or busy when your message arrives, you'll see a hint — ("⏳ integrating context…" / "🔄 queued…") — and the message is queued either way, processed as soon as the compact / current turn finishes. This includes messages sent while an auto-compact is running mid-turn — they run right after it ends.
|
|
88
88
|
|
|
89
89
|
## Interrupt vs Stop
|
|
90
90
|
|
|
@@ -94,6 +94,8 @@ Two ways to interrupt the agent:
|
|
|
94
94
|
|
|
95
95
|
**Hard stop**: click the red ⏹ button. Immediate abort, queue cleared, no checkpoint waiting.
|
|
96
96
|
|
|
97
|
+
**Exception — compaction.** An auto-compact (triggered mid-turn when context reaches `compressAt`) can't be interrupted: ⏹ or Esc during it only takes effect once the compact finishes, and the turn doesn't continue afterwards. A manual `/session compact` can be cancelled with ⏹ — history is rolled back and you'll see `Compact cancelled`.
|
|
98
|
+
|
|
97
99
|
## Token ring
|
|
98
100
|
|
|
99
101
|
The ring in the bottom-right of the input shows context window usage:
|
|
@@ -102,7 +104,7 @@ The ring in the bottom-right of the input shows context window usage:
|
|
|
102
104
|
- Orange: 70–90%
|
|
103
105
|
- Red: > 90%
|
|
104
106
|
|
|
105
|
-
Reaching `compressAt` (default 70%) auto-triggers compact. While the agent is running you can't click the TokenRing (guarded by `isStreaming`).
|
|
107
|
+
Reaching `compressAt` (default 70%) auto-triggers compact. If the LLM summary fails or times out, Halo falls back to a local compact (notice: `Auto-compacted N older messages (local fallback — LLM summary failed: <reason>)`) rather than retrying. An auto-compact has no overall time limit — it only times out after 10 minutes with no data from the model. While the agent is running you can't click the TokenRing (guarded by `isStreaming`).
|
|
106
108
|
|
|
107
109
|
## Session history
|
|
108
110
|
|
|
@@ -24,15 +24,15 @@ The primary surface for talking to an agent.
|
|
|
24
24
|
- Tool-call card: expandable, shows tool name / input / output
|
|
25
25
|
- Sub-agent messages carry the agent-name label (e.g. "Coder", "Researcher")
|
|
26
26
|
- Streaming text has a cursor animation
|
|
27
|
-
- The user bubble shows its send time (`HH:mm`, browser-local)
|
|
27
|
+
- The user bubble shows its send time (`HH:mm`, browser-local) at the bubble's bottom-right, right edge aligned with the Delete icon above it; hovering it gives the full date. Same bubble component as the Sessions tab, so both surfaces get it
|
|
28
28
|
|
|
29
29
|
### Debug toggle
|
|
30
30
|
A Bug-icon **Debug** button in the composer's left control cluster (next to the session-list / new-session buttons) switches the message list into the same debug rendering as the Sessions tab — see [requirements/session.md → Debug mode](session.md#debug-mode) for what it shows (tool calls, usage lines with token counts / latency / model, sub-agent start/done markers, thinking blocks). Persists in `localStorage` under its own key `halo_chat_debug`, independent of the Sessions tab's `halo_session_debug`. No Prompt button here — the system prompt viewer stays a Sessions-tab feature.
|
|
31
31
|
|
|
32
|
-
### User-message actions (
|
|
33
|
-
|
|
32
|
+
### User-message actions (Expand / Copy / Delete)
|
|
33
|
+
Actions on user-role turns: the blue sticky user bubble, plus the sub-agent-report (green) and compact-summary (purple) callouts — all three share the same Copy/Delete pair. The icons are **always visible** (not hover-only). On the callouts, Copy copies the body only (the `(from: session X)` / `[Conversation Summary…]` marker line is stripped); a deleted callout greys out and gains a "deleted" badge like the bubble does:
|
|
34
|
+
- **Expand / Collapse** — an arrow icon (chevron-down to expand, chevron-up to collapse) to the left of Copy, on the user bubble and the report callout; it appears only when the body is actually clamped, so short bodies have no arrow. (The compact-summary callout is collapsed by default and toggles from its own header instead)
|
|
34
35
|
- **Copy** — copies the prompt text to the clipboard
|
|
35
|
-
- **Show more/less** — the existing clamp toggle, shown when the text overflows
|
|
36
36
|
- **Delete** (confirm dialog) — removes the whole exchange (the user turn + all responses up to the next user turn) with **two-layer semantics**: the LLM context (`rawMessages`) drops the turn physically — the model never sees it again, freeing context; the UI keeps the messages, rendered greyed-out with a "deleted" tag, as an audit trail. No undo; a deleted exchange loses its Delete button. Rejected with an error toast while the agent is running or compacting. Root sessions only (sub-session logs don't offer Delete). If the turn was already compacted out of raw context, only the UI marking happens (silent degrade). Once a session has **archived history** the action is refused for the whole session with a plain explanatory notice (not an `Error:` bubble) — per-turn positions can no longer be mapped once older turns left the active file. Design details in [design/session.md](../design/session.md#exchange-deletion-soft-ui--hard-raw), protocol in [design/ws.md](../design/ws.md).
|
|
37
37
|
|
|
38
38
|
### Archived history (scroll up to load)
|
|
@@ -70,6 +70,8 @@ See [requirements/command.md](command.md) for the full command surface.
|
|
|
70
70
|
### Graceful interrupt
|
|
71
71
|
Sending a new message while the agent is generating **does not** abort — the message goes to the server queue, the agent finishes the current turn at the next safe checkpoint (after a tool call), and then runs the queued message. Queueing multiple messages is supported; they run in order.
|
|
72
72
|
|
|
73
|
+
**Live placement.** When a main user message or notification lands while the main bubble is still streaming, the live view lays it out the way the server persists it, so it matches what a reload shows. Before logging such a row the server runs `flushCompletedAssistantMessage`: the streaming content up to its first pending tool call settles **above** the row, and the rest (or a fresh empty streaming slot) continues streaming **below** it (`placeAroundStreaming` in `chat-store.ts`).
|
|
74
|
+
|
|
73
75
|
### Stop
|
|
74
76
|
The Stop button hard-aborts — the server receives `chat:stop`, AbortController fires, queue clears, buffers flush.
|
|
75
77
|
|
|
@@ -95,7 +97,7 @@ Two source kinds in the chat-input toolbar, **mutually exclusive** (only one bou
|
|
|
95
97
|
- **Screen / window share** (MonitorUp button) — opens a picker grid of screens + app windows. A bound window can be grabbed even while it sits in the background.
|
|
96
98
|
- **Camera** (Camera button) — toggles the webcam on. Hidden entirely on a machine with no camera.
|
|
97
99
|
|
|
98
|
-
Once bound, a frame is **not** attached to every message. Instead a one-line instruction is injected into the next send ("the user is sharing the «X» window" / "the user has turned the camera on") telling the model to output a line containing exactly `<<<CAPTURE>>>` when it needs to see the current view. On turn completion the frontend detects the marker, grabs one frame, and sends it back as a **visible image message** — the model sees it on its following turn. So it's a cross-turn round-trip: model asks → frame is sent back → model answers next turn. The returned frame is also shown inline on the user bubble so you can see exactly what was sent.
|
|
100
|
+
Once bound, a frame is **not** attached to every message. Instead a one-line instruction is injected into the next send ("the user is sharing the «X» window" / "the user has turned the camera on") telling the model to output a line containing exactly `<<<CAPTURE>>>` when it needs to see the current view. On turn completion the frontend detects the marker in any of the turn's replies, grabs one frame, and sends it back as a **visible image message** — the model sees it on its following turn. So it's a cross-turn round-trip: model asks → frame is sent back → model answers next turn. The returned frame is also shown inline on the user bubble so you can see exactly what was sent.
|
|
99
101
|
|
|
100
102
|
Constraints:
|
|
101
103
|
- Shown only when the selected agent's model accepts image input (capture is pointless on a text-only model); switching to a text-only model auto-unbinds.
|
|
@@ -57,6 +57,7 @@ Adding new file types is a plugin concern — see `dev/previews.md` for the exte
|
|
|
57
57
|
### Preview extensions (installable viewers)
|
|
58
58
|
File types without a built-in plugin can be handled by an **installed extension**: a static HTML bundle under `~/.halo/global/extensions/<id>/` (server-wide, not per workspace) that Canvas loads in a sandboxed iframe (scripts + same-origin, like the HTML preview — an extension is trusted like a skill the user chose to install). Design: `design/canvas-extensions.md`.
|
|
59
59
|
- **Resolution order** for a file extension: `default`-priority extensions (newest install wins) → built-in plugins → `option`-priority extensions. `.glb` with the `glb` extension installed opens in the viewer directly; an `option` extension only shows up in the header's **Open with** menu
|
|
60
|
+
- **Opening and tab restore**: whether a file opens as text or in a viewer is decided when it is clicked, restored after a page reload, or opened to the side. Since an installed extension may claim a type (`.glb`) or even a text suffix (`.json`), a file that would open as text first waits for the page's initial extension list (up to 3 s), so a `.glb` tab survives a reload as a viewer rather than coming back as text. Files that already have a built-in viewer open without waiting. If the list isn't back within 3 s (or failed to load), files open as if no extension were installed — a `.glb` opens as text; a list that arrives later still updates the viewer of tabs that are already previews
|
|
60
61
|
- **Open with** (header menu, shown only when there are >2 candidates counting *Open as text*): switches the current tab's viewer; the choice is per tab and not persisted
|
|
61
62
|
- **Fallback**: a file with no built-in plugin and no installed extension shows a static page — "no built-in preview for this type; extensions for more file types are on halo-hub ↗" — plus Open as text / Download. No online lookup
|
|
62
63
|
- **Editable extensions** (manifest `capabilities: ["save"]`): the iframe reports dirty → tab shows the dirty dot → header **Save** button (Ctrl/Cmd+S is *not* wired for extension tabs — Monaco isn't mounted) → `PUT /api/files/raw`. If the file changed on disk since it was loaded the save gets a 409 and the user is asked "changed on disk … Overwrite?". An external change while the tab is clean reloads it; while dirty, local edits are kept silently and the conflict surfaces at the next save
|