# VeilCLI — Edge-Case Bug Audit (June 2026)

> **FIX STATUS (2026-06-10): applied.** Everything below has been fixed in the working tree (59 files) except the items listed here, which were deliberately deferred:
>
> - **§4.9-L8** (event-mapper `thinkingTokens` always 0) — cosmetic; the Claude SDK exposes no reliable reasoning-token field to map.
> - **§3.15/agent_control L4** (activity-baseline pollution) — needs a product decision on what counts as "contact"; behavior unchanged.
> - **agent_message merged-cid leak (L11)** — re-analysis showed merged rows are `async_inform=1`, which wake.js *does* drain; likely not a real leak. Left as-is.
> - **Mutual A→B / B→A sync deadlock** — only the guaranteed self-send case is guarded (new VALIDATION_ERROR); detecting cross-pair cycles needs a wait-graph. Both sides still unblock at their sync timeout.
> - **UI §7.4 "live tool stdout dead" and O(n²) streaming render** — need a bubble-lifecycle/incremental-render refactor; the feed-view unbounded growth (the freeze you'd actually hit) IS fixed via event/row caps.
> - **`pushSilent`** stays exported-but-unused (documented broken in the code).
>
> Behavior changes worth knowing:
> - Chat turns that exhaust retries/limits now return **errors** (`LLM_ERROR`, `MAX_ITERATIONS`, `MAX_DURATION`) instead of a success-shaped empty reply.
> - `POST /sessions/:id/compact` returns **409 SESSION_BUSY** while a turn is active.
> - PreToolUse hooks now **fail closed** on spawn-level failures (E2BIG/ENOENT/timeout) — a deny-hook that cannot run denies the call.
> - Daemon `conflictPolicy: "queue"` now actually queues (one coalesced catch-up tick); `"restart"` is documented as unsupported (skip).
> - `settings.memory.enabled/maxLines` are now honored; docs updated (08-memory.md, 03-configuration.md, 10-daemons.md, api/03-chat.md, api/05-sessions.md, api/08-settings.md).
> - Verification: smoke suite 27/27 (1 pre-existing unrelated failure), integration suite identical pass/fail to pre-fix baseline (its 27 failures are environmental — missing `hello` agent fixture), plus targeted empirical tests for grep/glob/edit_file/shell-manager/web_fetch fixes.

Full-codebase review (~21.5k lines: core, engines, llm, tools, api, cli, infrastructure, utils, ui) hunting for the edge-case failures that don't show up in normal use. Every finding below was verified by reading the actual code at the cited lines; items marked **[verified empirically]** were additionally reproduced in a standalone Node script.

Severity legend: **CRIT** = data loss / session permanently broken / security; **HIGH** = wrong results or hangs under a realistic edge case; **MED** = misbehavior in a narrower window; **LOW** = papercut / slow leak.

---

## Top 10 — most likely sources of your "edge case" problems

These are the ones whose symptoms match "works fine normally, breaks occasionally":

1. **Auto-compaction mid-loop wipes the system prompt** (§2.1) — any session crossing `compact_auto_threshold` mid-turn finishes the turn with NO system prompt and no actual context reduction.
2. **Compaction cuts mid tool-call pair → session 400s on every turn afterward** (§2.2) — persisted `compact_size` makes it permanent until the next compaction.
3. **`getMessages` LIMIT 1000 drops the NEWEST messages** (§5.3) — at exactly 1000 rows, the rebuilt context is the *oldest* 1000; the just-sent user message isn't in it.
4. **grep tool misses matches on consecutive lines** (§3.1) — stateful `g`-flag regex; agents conclude code doesn't exist when it does. [verified empirically]
5. **edit_file corrupts content containing `$$` / `$&`** (§3.2) — JS replacement-pattern expansion; silently writes wrong file content. [verified empirically]
6. **Claude-cli injection: 30-minute hang + lost message** when the runtime exits between the "is running" check and the push (§4.1) — the documented recovery path doesn't exist in code.
7. **Mid-stream provider errors swallowed → truncated reply returned as success** (§4.4) — OpenRouter `data: {"error":...}` events are skipped; partial content persisted with `finishReason: 'stop'`.
8. **Concurrent chats to the same claude-cli session run two SDK loops at once** (§1.1) — no per-session lock on the claude path (the openai path has one).
9. **Budget breach in task mode leaves the task `processing` forever** (§1.3) — and cancels the wrong registry key.
10. **Background shell output buffers grow unbounded → OOM of the whole runtime** (§3.4) — plus `kill_shell` never kills the child process tree (§3.5).

---

## 1. Core orchestration (`core/router.js`, `core/loop.js`, `core/queue.js`, `core/cancel.js`)

### 1.1 CRIT — No per-session entry lock on the claude-cli chat path
`core/router.js:741,796`. The openai branch serializes `runLoop` entry via `_withRunChatLoopLock` (router.js:475) and re-checks `runningSessions` under the lock. The claude branch does only an unlocked `runningSessions.has(sid)` check, and registration happens inside `runClaudeSession` *after* `await loadSdk()` (claude-engine.js:222-229) — a real async window. Two rapid POSTs to the same idle session → two SDK processes resuming the same `claude_session_id` concurrently. Compounding it, `cancelRegistry.register('session:'+sid)` blindly overwrites (cancel.js:21-25), so the first run becomes uncancellable and the first run's `finally` cleanup deletes the *second* run's controller. Symptom: interleaved/duplicated assistant messages, cancel stops working, orphaned SDK runtime.

### 1.2 CRIT — Self-deadlock in the openai injection race-recovery path bricks the session
`core/router.js:476-481` + `198-204`. `_withRunChatLoopLock` routes to `injectIntoActiveOpenaiSession` *while holding the lock*. Inside, the race-recovery branch (`if (!runningSessions.has(sid)) { ...; return await runChat({...continueFlag:true}) }`) recursively calls `runChat`, which queues behind the very lock entry currently awaiting this recursion → circular await. The HTTP request hangs forever and **every subsequent `runChat` for that session queues behind it** until restart. Trigger: the re-check finds a loop registered by a non-lock-holding path (task/subagent loop on the session, or engine flip) which then exits without draining the injected row — exactly the race this branch exists to recover.

### 1.3 HIGH — Session-budget breach in task/subagent mode leaves task `processing` forever
`core/loop.js:574-610`. On breach the code calls `cancelRegistry.cancel('session:'+sessionId)` — but tasks register under `task:<taskId>` (router.js:1001,1113,1175), so the cancel is a no-op — and then `return`s with **no `updateTask`**: no failed status, no `finishedAt`, no subscriber notification. Sync `task_spawn` callers get `output: null, status: 'processing'`; async subscribers hang forever.

### 1.4 HIGH — Chat-mode terminal failures return success-shaped `content: null`
`core/loop.js:450-459, 285-316, 557-568`. After 4 failed LLM attempts (or maxIterations / maxDuration / legacy tokenBudget exit), task mode marks the task failed — but chat mode just `return`s without throwing or yielding `chat.response`. `runChat` resolves normally with `responseContent: null, cancelled: false`. HTTP callers and spawning agents treat a provider outage as a successful empty reply.

### 1.5 HIGH — `pendingCorrelationIds` leak on limit/budget/empty-content exits
`core/loop.js`. The flush of drained-but-unanswered correlation ids exists only on the two cancellation checks (268-274, 638-644) and the LLM-error path (442-448). The maxIterations, maxDuration, tokenBudget, session-budget exits — and the normal exit when the final assistant content is empty (flush at 491 is gated `if (content && ...)`) — all leak them. The rows are already `status='delivered'` (queue.js:50) so no later drain can recover them. Symptom: sync `agent_message` pollers block the full 600 s timeout; `async_inform` senders never informed.

### 1.6 HIGH — Mid-turn injection persists the user message twice
`core/router.js:153` + `core/loop.js:337-342`. `injectIntoActiveOpenaiSession` persists the message and emits `chat.user_message`; the loop then drains the queue row and persists/emits it **again** (USER_SENTINEL rows render as raw content, queue.js:20). Every POST /chat into an actively running openai session → duplicate user row in the DB and duplicate bus event; all subsequent turns send the message twice to the model. (Related: the claude-engine race-recovery startup drain at claude-engine.js:347-358 has the same double-persist.)

### 1.7 HIGH — `task_spawn wait:false` / `task_create` tasks are never dispatched
`tools/task_spawn.js:41-43`, `tools/task_create.js`. The only callers of `runTask`/`runSubagent` are POST /tasks, the CLI, and `task_spawn wait:true`. There is no background pending-task poller. An async-spawned task sits `pending` forever; the spawning agent polls `task_status` indefinitely. ("Parallel fan-out" as advertised in the tool description does not work.)

### 1.8 MED — Per-task limit overrides stored but never applied
`infrastructure/database.js:703-710` persists `max_iterations`, `max_duration_seconds`, `token_budget` per task; nothing in router.js/loop.js ever reads them — limits come solely from `getEffectiveModeConfig(agent,'task',settings)`. A task created with `maxIterations: 5` runs the agent default.

### 1.9 MED — Exceptions escaping `runLoop` leave tasks stuck `processing`
`core/router.js` runTask (917-1018), resumeTask (1074-1130), runSubagent (1138-1212) wrap the loop in try/**finally** only. A throw (compaction error rethrown at loop.js:375-379, DB error, agent deleted mid-run) escapes with the task still `processing`. POST /tasks' catch sets `failed` but without `finishedAt`/events/subscriber notify; POST /tasks/:id/respond's catch only `console.error`s (resumed task stuck forever); `task_spawn`'s catch never updates the task row.

### 1.10 MED — AJV validator cache never invalidated despite tool hot-reload
`core/registry.js:295-297`. `loadCustomTool` deliberately busts `require.cache` so schemas reload from disk, but the compiled input validator is cached forever (`!_validators.has(name)`). Edit a custom tool's `input_schema` while running → LLM sees the new schema, validation enforces the old one. Also two same-named tools at different levels share one validator slot.

### 1.11 MED — Tool timeout race: timer never cleared, timed-out tool keeps running
`core/loop.js:704-707`. The `Promise.race` watchdog `setTimeout` is never cleared on success — `bash`/`task_spawn`/`agent_message` declare `timeout: 86400` → every call leaves a ref'd 24-hour timer (a CLI one-shot process can't exit naturally; a busy server accumulates thousands). On timeout, the losing tool keeps executing with side effects and keeps emitting `tool.chunk` events after its `tool.end`, corrupting UI event pairing.

### 1.12 LOW — `resolveEngine` swallows all errors and silently falls back to openai
`core/router.js:23-31`. A transient settings/provider-resolution failure for a claude-cli agent routes the turn through the openai loop against claude-built history. One-off garbled turns that vanish on retry.

### 1.13 LOW — taskBrief breaks on multimodal first message
`core/loop.js:320,365`. `content.slice(0,200)` on a parts array yields `[object Object]` in the anti-drift reminder and compaction taskBrief.

### 1.14 LOW — `_sessionRunContext` map grows unbounded
`core/wake.js:43-49`. Set on every chat turn (router.js:627,814), never deleted; pins a full settings object per session for the life of the process. Same class: `utils/summarizer-rate-limit.js:6` `_buckets`.

---

## 2. Compaction & context rebuild (`core/default-compaction.js`, `core/compaction.js`)

These align with the known compaction issues in TODO.md — carry them as acceptance criteria for the rewrite.

### 2.1 CRIT — Mid-loop auto-compaction destroys the system prompt and doesn't trim
`core/loop.js:347-361` + `core/default-compaction.js:216-229`. System prompts are no longer persisted as DB rows (router.js:551-563), but `maybeAutoCompact` reloads messages from DB and `buildMessagesWithSummary` hits `if (systemMsgs.length === 0) return messages;` — returning the **full untrimmed history with no system message**. The loop then does `messages.length = 0; push(...autoCompacted)`, wiping the in-memory system prompt; the custom-tools re-append no-ops too. Since context wasn't reduced, the threshold stays exceeded and `runDefaultCompaction` fires an extra summarization LLM call **every subsequent iteration** (cost burn) while the agent runs with no identity/rules.

### 2.2 CRIT — Compaction boundary ignores tool-call pairing → permanent provider 400s
`core/default-compaction.js:138-149`. The cutoff is purely character-count-based; nothing prevents it landing between an assistant `tool_calls` message and its `role:'tool'` results. `buildMessagesWithSummary` then slices there and OpenAI-compatible providers reject with 400 ("tool message must follow..."). Because `compact_size` is **persisted** (line 188), the session 400s on *every* turn until another compaction happens to move the boundary.

### 2.3 HIGH — Second compaction can swallow ALL live messages
`core/default-compaction.js:130-141`. `targetLength = (summaryLen + uncompactedLen) * compactCount/100`. Once `summaryLen > uncompactedLen` (normal after the first compaction at default 50%), the batch loop runs to the end and compacts **everything**, including the in-flight user message — the model is then called with a summary and an empty tail.

### 2.4 HIGH — `resetSession` doesn't clear `compact_summary`/`compact_size`
`infrastructure/database.js:371-387` (called by `api/routes/sessions.js:836`). After reset, `buildMessagesWithSummary` still slices `compactSize` messages off the *fresh* conversation (the agent ignores the user's first N messages) and injects the summary of the wiped conversation.

### 2.5 HIGH — Legacy compaction (`core/compaction.js`): three issues
- Line 156: `estimateContextUsage(messages)` never receives the real model context window (param defaults to 100000) — 8-32k models overflow long before compaction triggers; 1M models compact at ~10% usage.
- Lines 120-127: a *successful* compact call returning empty content replaces the whole history with `[compaction produced no summary]` — only thrown errors take the trim fallback.
- Lines 100,132: the `.slice(-20)` fallback can itself start on an orphan `tool` message → 400; and the compacted result ends on an assistant message, which Anthropic-style providers reject as the final role.

### 2.6 MED — Manual `POST /sessions/:id/compact` has no SESSION_BUSY guard
`api/routes/sessions.js:886-891`. Unlike `/reset` and `/trim`, it runs `runDefaultCompaction` while a turn may be mid-flight, moving `compact_size` under the running loop.

### 2.7 MED — `deleteMessage` inside the compacted prefix shifts the boundary by one
`infrastructure/database.js:569-580`. Only clamps `compact_size` to the count; deleting an already-compacted row makes `slice(compactSize)` hide one *live* message (and can orphan a tool pair). Same in `deleteMessagesAfter`.

---

## 3. Tools & shell (`tools/*`, `core/shell-manager.js`) — several [verified empirically]

### 3.1 CRIT — `grep` misses matches on consecutive lines [verified empirically]
`tools/grep.js:59,37`. `new RegExp(pattern, 'g')` + `.test()` in a loop: the `g` flag keeps `lastIndex` across calls. Pattern `foo` against three consecutive matching lines returns match/miss/match. Agents conclude code doesn't exist when it does. Fix: drop the `g` flag.

### 3.2 CRIT — `edit_file` corrupts files when `new_string` contains `$` patterns [verified empirically]
`tools/edit_file.js:74`. `content.replace(old, new)` interprets `$$`, `$&`, `` $` ``, `$'` in the replacement. Replacing with `PID=$$` writes `PID=$`; with `x$&y` re-inserts the old string. Bites on Makefiles, shell scripts, CI yaml `${{ }}`. Fix: `replace(old, () => new_string)`.

### 3.3 CRIT — `read_file` on a FIFO/device file freezes the entire server
`tools/read_file.js:65-66,120`. Only directories are rejected; `readFileSync` on a named pipe blocks the event loop synchronously, so the `Promise.race` watchdog can never fire — the whole Node process (all sessions, SSE, shell readers) hangs. `/dev/zero` OOMs instead. Fix: `stat.isFIFO()/isSocket()/isCharacterDevice()` guard + size cap.

### 3.4 HIGH — Shell output buffers grow without bound
`core/shell-manager.js:122-137,172-176`. Background shells never run `_enforceCap`; nothing ever truncates `stdoutText`/`stderrText`, exited background entries are never reaped until `closeSession`, and foreground shells keep the full concatenated transcript of every command for the session's life. A chatty `npm run dev` left backgrounded for hours OOMs the runtime. Also `tools/bash_output.js:28-55` returns the entire un-polled delta with no size cap — one poll can inject tens of MB into context.

### 3.5 HIGH — Killing shells never kills the child process tree
`core/shell-manager.js:390-395,442-447,280`. Spawned with `detached: false`, no process group; kill/timeout/exit paths signal only the bash PID. `bash -c 'npm run dev'` + `kill_shell` → dev server keeps running, holds the port, agent believes it's dead. Fix: `detached: true` + `process.kill(-pid)`.

### 3.6 HIGH — No tool-result truncation anywhere in the loop
`core/loop.js:760` (explicit comment: "no truncation"). Unbounded producers: `web_fetch` base64/json modes (entire body, no cap — and each `page:N` re-downloads the whole body), `read_file` (2000-*line* limit; a one-line 50 MB minified bundle returns in full), `glob` (all matches joined), `task_status`/`task_spawn` (full task output verbatim). One edge-case call blows the context window or the provider request-size limit.

### 3.7 HIGH — Foreground shell protocol breaks on stdin-reading commands
`core/shell-manager.js:368-372`. The command + `__veil_rc=$?` + sentinel printf are written to the shell's stdin as one payload; any command that reads stdin (`cat`, `head -1`, `read`, REPLs) consumes the protocol lines as data. Either a 120 s hang + shell recycle (losing `cd`/`export` state), or — when only some lines are eaten — the sentinel prints with empty `$__veil_rc` and the tool reports **exit code 0 regardless of the real result**. Fix: redirect stdin from `/dev/null` per command.

### 3.8 HIGH — `task_respond` routes the reply by agent name only
`tools/task_respond.js:24-29` enqueues with no `targetSessionId`; `core/queue.js:42` drains `target_session_id IS NULL` rows into **any** session of that agent. With two active sessions of the same agent, the response lands in an unrelated conversation and the waiting task is flipped to `processing` (line 31) without ever receiving it — permanently stalled.

### 3.9 MED — `agent_message` edge cases
- **Double delivery** (`agent_message.js:361-388`): the poll loop's recovery deletes its row `WHERE status='pending'` and re-fires `runChat` — but `drainNonFollowup` marks rows `delivered` before any response exists, so the DELETE no-ops and the target processes the message twice.
- **No self/cycle guard** (`agent_message.js:260`): sync message to your own sessionId (or mutual A→B / B→A) is a guaranteed deadlock until the 600 s timeout.
- **Timeout leaks merged rows** (`agent_message.js:403-408`): the finally deletes only the poller's own correlationId; merged `async_inform` cids stay `pending` forever.

### 3.10 MED — Glob/grep pattern semantics broken at the edges [verified empirically]
`tools/glob.js:24-31,42`: `**/*.js` does NOT match root-level `a.js`; `?` is untranslated (stays a regex quantifier — `file?.js` matches `fil.js` but not `fileX.js`); ignore check is a raw prefix match (`node_modules/**` also excludes `node_modules-backup/`). `tools/grep.js:26`: include glob `*.js` becomes `^.*.js$` and matches `foojs`.

### 3.11 MED — File read/edit tracker gaps
- A file created with `write_file` cannot then be edited — tracker only matches `read_file` calls (`utils/file-read-tracker.js:34`, `tools/edit_file.js:46`).
- Path comparison is strict string equality (tracker line 43): `/a//b.txt` vs `/a/b.txt` or symlink aliases falsely block edits.
- CRLF files are effectively uneditable: `read_file` splits on `\n` only, the model copies text without `\r`, `edit_file` exact-match never finds it → endless retry loops.
- Binary files: mtime recorded *before* the binary check (`read_file.js:69` vs 121), so `write_file` on a binary the agent never saw passes both gates.

### 3.12 MED — Foreground mutex race after timeout-recycle
`core/shell-manager.js:347-380,271-284,89`. The mutex lives on the entry; recycle deletes the entry, so a queued waiter and a fresh caller can both run on the new shell, overwriting `entry.currentCmd` — one command's output matched against the other's sentinel.

### 3.13 MED — `task_spawn` has no recursion/depth guard
`tools/task_spawn.js:21-62` (contrast `agent_spawn.js:119-146`). An agent that `task_spawn`s its own agent with `wait:true` recurses inline, each level awaiting the next with a 24 h timeout.

### 3.14 MED — `web_fetch` hardening gaps
`tools/web_fetch.js:21-40`. No AbortController (watchdog abandons but the transfer continues), no size cap before `response.text()`, each page call re-downloads the full body (boundaries shift if content changes), no scheme/host filtering (`file://` throws raw; localhost/metadata URLs proxied freely).

### 3.15 LOW — assorted
- `edit_file` empty `old_string` passes the single-occurrence gate and silently prepends (`edit_file.js:65`) [verified empirically].
- `utils/atomic-write.js:65-66`: no fsync of file or directory — power loss can leave zero-length/stale files despite the "never partial" claim (process-crash safety is fine).
- `bash.js:146` `_oneShot`: `err.status || 1` — SIGKILL'd commands (status null) report exit 1; success-path stderr is discarded.
- `bash_output` accepts foreground shell ids and returns raw `__VEIL_SENTINEL__` protocol lines (no `background` check in `shell-manager.read()`).
- `todo_write.js:31-35` persists malformed items unvalidated (the claude-cli MCP transport flattens schemas to `z.any()`, per agent_control.js:31-33); a non-array payload makes every subsequent `getTodos` parse/`.map` throw.
- `memory_search.js:50` drops query words ≤2 chars — short identifiers in memory are unsearchable.
- `task_subscribe.js:22-32` TOCTOU: task can finish between the status check and subscription creation → subscriber waits forever.
- `web_search.js:32-41` returns DDG redirect URLs verbatim with undecoded HTML entities; any DDG markup change silently yields "No results found".
- `list_dir` aborts entirely on one broken symlink (`list_dir.js:30,58` — unwrapped `statSync`).
- Sync walkers (`glob`, `grep`, `list_dir`, `memory_search`) block the event loop on huge trees/NFS — nothing in the process runs, and schema timeouts can't fire.

---

## 4. Engines & LLM layer (`engines/claude-engine.js`, `llm/client.js`, `llm/provider.js`)

### 4.1 CRIT — Claude-cli injection orphaned when the runtime exits during the push window
`core/router.js:311-345` + `engines/claude-engine.js:795-833`. Two windows:
- `pushToExistingSession` returns false because the runtime was removed → the comment claims the engine's finally-drain will pick it up, but that drain has **already run** (that's why the runtime is gone), and the documented mid-poll recovery ("recurse via runChat({continueFlag:true})", router.js:278-280) **does not exist in the code** — unlike the openai sibling (router.js:198-204). The HTTP caller blocks the full `INJECTION_TIMEOUT_MS = 30 min`, then INTERNAL_ERROR.
- Push succeeds but the runtime entry is gone before `getRuntime(sid)` → the row is already `markAgentMessageDelivered` (so no pending-drain can find it) and the cid never joins `_pendingDirectPushCids` (so the finally-drain audit can't close it). Same 30-minute hang; message silently dropped.
Also (`router.js:347-355` + `queue.js:212-229`): on injection timeout the row keeps `status='pending'`, so the *next* turn's `drainUserSentinelOnly` replays the stale, already-errored message as a surprise user message — and `markAgentMessageDelivered(msg.id)` called with one arg nulls the audit text.

### 4.2 HIGH — Direct-push injection can be answered with a stale reply, and the message never reaches the model
`engines/claude-engine.js:447,701-715`. `lastContent = textContent || lastContent` persists across turns; the result handler posts `lastContent` as the correlated response even when the injection arrived *after* that text was generated (Case B). The message is still in the SDK promptQueue when the engine yields `chat.response`; the consumer breaks, teardown discards the queue — and since the row was already marked delivered, it's never re-drained. The caller got a "reply", the DB has the user row, the model **never saw the message**.

### 4.3 HIGH — `currentToolCalls` overwritten per assistant event → tool attribution breaks; mid-turn text treated as final
`engines/claude-engine.js:539,563-565,493-494`. The CLI can emit one assistant event per content block (same message.id). A text-only event clobbers the tool-call lookup table before the `tool_result` arrives (`toolName: 'unknown'` in tool.end), and `isFinalIteration = no tool calls in this event` treats a "Let me check..." preamble as the final response — injection pollers receive it as the correlated reply.

### 4.4 HIGH — Provider error payloads swallowed; truncated streams returned as success
`llm/client.js:134-141,196-220`. The SSE parser has no handling of `chunk.error` (OpenRouter delivers upstream/moderation failures as `data: {"error":...}` mid-stream then closes) — the event is skipped and the partial content returns with `finishReason: 'stop'`. Non-streaming: a 200 body of `{"error":...}` passes `response.ok` and later throws the misleading "LLM response had no choices" *after* fallback already returned success. Incomplete tool_call argument buffers from a cut stream are returned as-is.

### 4.5 HIGH — No AbortSignal or timeout on any LLM HTTP call
`llm/client.js:70-74,94-98`; `core/loop.js:411-427` passes no signal. Cancelling a session does nothing to the in-flight request — streaming reads continue to completion (billing included) and the loop only notices the abort afterward. A wedged endpoint that trickles bytes stalls the turn indefinitely with no retry/fallback (the call never "fails").

### 4.6 MED — Provider fallback logic gaps
- `llm/provider.js:139-151`: the streaming-safety guard counts *skipped* providers — a chain starting with a claude-cli provider throws "not callable via HTTP" without ever trying the openai fallback (bites custom tools' in-process LLM calls, commit e95658f).
- `provider.js:133` vs `client.js:63`: `isStreaming` detection mismatch (`onThinkingChunk`/audio stream in client but classify non-streaming in provider) → after a mid-stream ECONNRESET, fallback re-streams and the caller receives provider 1's partial deltas followed by provider 2's full stream.
- `llm/client.js:12`: non-standard top-level `cache_control` sent unconditionally — strict OpenAI-compatible endpoints 400 every call, and a 400 is non-retriable so the **fallback chain never engages**.

### 4.7 MED — `execSync` hooks block the whole process; oversized input makes deny-hooks fail open
`engines/claude-engine.js:129-183`. Pre/PostToolUse hooks run via `execSync(..., {timeout: 10000})` — up to 20 s of full-process freeze per tool call. And `VEIL_TOOL_INPUT` as an env var: a >~128 KB tool input makes `execSync` throw `E2BIG`, which has no `.status`, so the handler's `if (err.status === 1) deny` falls through and **allows** the call — security hooks bypassed exactly on the largest inputs.

### 4.8 MED — SDK stream ending without a `result` event = silent empty success in chat mode
`engines/claude-engine.js:765-774`. Only task/subagent/daemon modes get a synthesized terminal event; chat mode yields nothing, `runClaudeChat` ends with `responseContent: null` and emits a normal `chat.response`. (Same family as §1.4.)

### 4.9 LOW — assorted
- Streaming usage lost on providers requiring `stream_options: {include_usage: true}` (never set) → zeros recorded, budgets silently broken on those endpoints (`client.js:11-32,208`).
- Final stream fragment without trailing newline discarded; no final `decoder.decode()` flush (multibyte char split at stream end lost) (`client.js:120-126`).
- `loadSdk` `catch { return null }` masks real import errors as "npm install the SDK" (`claude-engine.js:69-77`).
- `thinking` param silently dropped by `buildRequestBody` destructuring (`client.js:11,52,68,92`).
- Idle-runtime hang: `continueFlag` with empty message and zero drained rows starts the SDK with nothing pushed; `for await` blocks indefinitely (`claude-engine.js:342-359`).
- `pushSilent` is known-broken (SDK ignores `shouldQuery:false`, per the postmortem comment at claude-engine.js:543-549) but still exported (`claude-prompt-queue.js:57-65`); `push()` throws synchronously after `close()` and `pushToExistingSession` doesn't catch.

---

## 5. Persistence & infrastructure (`infrastructure/database.js`, `scheduler.js`, `core/memory.js`, `utils/`)

### 5.1 CRIT — Migrations 003/004 cascade-wipe all messages on legacy DBs
`infrastructure/database.js:29-31` sets `PRAGMA foreign_keys = ON` **before** `runMigrations`. Migrations 003/004 use the rename-copy-drop pattern with `DROP TABLE sessions` — with FKs on, that fires `ON DELETE CASCADE` and deletes **every row in messages and todos** before 004's copy step runs (it copies an empty table). 003 also permanently discards `instance_folder`/`title`/`message_count`, and the catch at database.js:67-72 masks partial failure as "Migration skipped". Any pre-003 database opened by a current build loses all chat history. Fix: `foreign_keys = OFF` during migrations (the standard SQLite recipe).

### 5.2 CRIT — Wake path marks queued sub-agent replies `delivered` before processing
`core/wake.js:156-189`. `drainNonUserSentinel` marks rows delivered (queue.js:265), *then* `runChat` runs; if it throws (bad settings, provider error, busy race, crash), the catch only logs — the rows are `delivered` and no later drain or wake ever picks them up. Parent agents silently never receive sub-agent results; orchestration stalls.

### 5.3 HIGH — `getMessages` default `LIMIT 1000` returns the OLDEST 1000
`infrastructure/database.js:493-496` (`ORDER BY id ASC LIMIT ?`), used bare by `core/router.js:509/943/1088`, `core/loop.js:46/58`, `core/default-compaction.js:119`. Past 1000 rows (easily reached with tool messages), the rebuilt context omits the newest messages — including the just-persisted user message — and compaction slices a truncated array.

### 5.4 MED — Cursor pagination compares random IDs against a `created_at` sort
`database.js:334-337,740-742`. `AND id < ?` with `ORDER BY created_at DESC` and random-hex ids → page 2 is a random subset with skips and duplicates.

### 5.5 MED — JSON columns parsed without try/catch
`database.js:499-501,537,797-798,822,846,998`. One corrupt `tool_calls`/`tags`/`todos` value (old version, manual edit, crash-truncated WAL) makes the entire transcript/task-list/todos read throw — the session becomes unopenable instead of degrading one field.

### 5.6 MED — Multi-statement writes without transactions
`forkSession` (database.js:625-690: session insert + N message inserts + compact update), `addMessage` (464-485: insert + counter update), `deleteMessage`/`deleteMessagesAfter`, `resetSession`. Crash mid-sequence leaves `message_count`/`compact_size` drift that nothing reconciles (and `message_count` drives the claude-cli `/compact` separator, sessions.js:874-875).

### 5.7 MED — Scheduler: `queue`/`restart` conflict policies are silently `skip`; stop doesn't stop in-flight ticks
`infrastructure/scheduler.js:46-50,88-95`. Both policies just `return` (node-cron queues nothing). `stopDaemon` doesn't cancel an in-flight async tick, and a config-reload `startDaemon` creates a fresh `isRunning` closure → old tick and new job's tick run `runDaemonTick` concurrently for the same agent. No timezone option; ticks missed during system sleep are dropped.

### 5.8 MED — Settings deep-merge: `null` subtree kills all turns; `__proto__` pollution
`utils/settings.js:14-24`. A project settings file with `"models": null` makes `getModelConfig` throw on every turn. `Object.keys` includes a literal `"__proto__"` own-property from JSON.parse and the object branch recurses into it → mutates `Object.prototype` from a user-edited file.

### 5.9 MED — `toXml` body intentionally unescaped
`utils/xml-format.js:66-73`. Scalar fields are escaped but the `content` body is raw — any sub-agent/tool output containing `</content>` or fake harness tags terminates the block early and injects spoofed fields into what the parent LLM parses as structured data. (Prompt-injection amplifier for hostile webpage content flowing through `agent_message`.)

### 5.10 MED — Memory archive rotation splits entries and breaks dedup
`core/memory.js:73-88`. `exportOldMemory` cuts at a raw line offset, ignoring `<!-- date -->` entry delimiters: MEMORY.md can start mid-entry, `_hasExactEntry` dedup then fails → the same memory re-appends repeatedly; rewrite is non-atomic (`writeFileSync`); and the single-process assumption doesn't hold when daemon + studio + CLI share a workspace.

### 5.11 LOW — assorted
- `recoverStaleTasks` only recovers `'processing'` — `'waiting'` tasks are stuck forever after a restart (database.js:1104-1110).
- `addMessage` `content || null` / `inputTokens || null`: empty-string tool results become NULL → rebuilt as `content: null` on a `role:'tool'` message → provider 400 (database.js:469-474).
- `updateSession`/`updateTask` with `{}` generates `SET , updated_at = ?` → SqliteError (database.js:346-349).
- `getMessagesAfter` parses `tool_calls` but not `attachments_metadata` — schema drift between the two read paths (database.js:535-538).
- `settings.memory.enabled`/`maxLines` are dead settings — defined in defaults, read by nothing; `memory.enabled: false` doesn't stop the pre-compaction memory extractor.
- `async-inform.js:93-132`: `emitTrace` runs outside the try; a trace-insert failure after the success reply was enqueued also enqueues "[Dispatch failed...]" — caller receives the answer *and* a contradictory failure notice.

---

## 6. API & CLI (`api/`, `cli/`)

### 6.1 CRIT — Path traversal in agent memory routes
`api/routes/memory.js:104,119,141-143,158`. `:name` is used unsanitized in `path.join` (only `:file` is checked). `PUT /agents/..%2f..%2f..%2ftmp/memory/pwn.md` writes an arbitrary `.md` file outside the workspace; GET/DELETE read/delete likewise; the list route discloses arbitrary directories' `.md` files. `core/agent.js` has a `/^[a-z0-9_-]+$/i` name validator — these routes never call it.

### 6.2 HIGH — `app.locals.cwd` is never set; models routes run on `undefined` cwd
`api/server.js:29-30` sets only `settings`/`scheduler`; `req.app.locals.cwd` is read in 7 places but assigned nowhere. `GET /models/custom` and `PUT /models/custom?level=project` **500** (`path.join(undefined,...)` throws outside the inner try); `GET /models` silently ignores project-level custom models; `completions.js:32` and `sessions.js:377` compute cost/context-limit without project models. These should use `context.getCwd()` like other routes.

### 6.3 HIGH — Secrets echoed by `GET /settings`
`api/routes/settings.js:33-59`. `redactSecrets` masks only keys literally named `api_key`; the server auth secret `settings.secret` (checked in middleware.js:14) and any token/password field is returned in cleartext. **Compounding bug (found in the UI review): the settings editor round-trips the redacted JSON back through PUT, which writes it verbatim (`settings.js:96`) — editing settings in the dashboard destroys real API keys on disk** (`ui/views/settings.js:61,85,106-126`).

### 6.4 MED — `POST /shutdown` exits abruptly
`api/routes/system.js:45-48`: `setTimeout(() => process.exit(0), 500)` — no `scheduler.stopAll()`, no `db.closeDb()`, no PID-file unlink (the CLI SIGTERM handler does all three, cli/index.js:171-181). In-flight tasks die unmarked; `veil status` reports a dead PID as running.

### 6.5 MED — Chat/completions SSE never cancel work on client disconnect
`api/routes/chat.js:92-147`, `api/routes/completions.js:68-107`: no `req.on('close')`, no cancel signal (contrast tasks.js:242, sessions.js:266). A disconnected client's turn runs to completion, tools and all, with no way to abort. Cost/resource leak.

### 6.6 MED — List endpoints 500 on non-numeric `limit`/`offset`; double-resume race
- `?limit=abc` → `parseInt` NaN → better-sqlite3 "datatype mismatch" → generic 500 (sessions.js:101, tasks.js:67,94-95, agents.js:128/147).
- `POST /tasks/:id/respond` (tasks.js:120-151): status checked, then resume fired in background with no lock — double-click/retry → duplicate enqueued messages and two concurrent resume loops on one task. `cancel` has the same read-then-write race against the `setImmediate(runTask)` dispatch.

### 6.7 LOW — assorted
- CLI parser (`cli/parser.js:85-113`, strict:false): `--agent --input hi` parses agent as the literal string `"--input"`; trailing `--agent` yields boolean `true` — both pass the `if (!agent)` guards.
- Task-creation numeric fields (`priority`, `maxIterations`, `tokenBudget`...) forwarded with no type checks (tasks.js:15,33-42) — though per §1.8 they're currently ignored anyway.
- Orchestration trace SSE replay writes without close-guard/try-catch → "Cannot set headers after they are sent" noise on disconnect during big replays (orchestration.js:83-133).

---

## 7. Web dashboard (`ui/`)

### 7.1 HIGH — Chat attachments are silently never sent
`ui/views/chat.js:584-617`. `this._attachments = []` (line 587) runs **before** `attsToSend = this._attachments.map(...)` (line 612) — the wire copy is always empty. The user bubble still renders chips (the pre-clear array reference was passed to the renderer), so it *looks* sent. The comment at 581 shows a reorder broke it.

### 7.2 HIGH — Settings editor destroys API keys (see §6.3 — UI half of the bug).

### 7.3 HIGH — `#chat/<sessionId>` refresh/deep-link silently starts a new session
`ui/app.js:226-241` maps the hash id for trace/sessions/tasks but not chat (falls to `params.id`, which `chat.mount` ignores — it wants `params.sessionId`, chat.js:113). The hash is then rewritten to bare `#chat`. The next message creates a brand-new server session while the user believes they're continuing the old one.

### 7.4 MED — assorted
- Live tool stdout is dead: `tool.chunk` arrives after the assistant message yield has set `_currentStreamEl = null` (server yields the message before executing tools, loop.js:524 vs 701) — chunks are buffered, never displayed, buffer deleted on tool result (chat.js:686-694,779,800).
- Resuming a long session loads the **oldest** 100 messages (`limit: 100` + server `ORDER BY id ASC`) — recent turns missing with no indicator (chat.js:1087).
- `innerHTML +=` in the task live-stream recreates all rows; `data-wired="1"` survives serialization but listeners don't → all earlier "Show full result" buttons permanently dead (tasks.js:435,300-301).
- `_loadHistory` has no staleness guard after its await → two quick session-resume clicks can render session A's history while `_sessionId === B`; fast navigation away mid-mount throws on null `inputEl` or attaches duplicate listeners (chat.js:1081-1102,110-198).
- Feed view: every WS message (including per-token stream chunks) appended with no cap; filter toggle re-renders the entire backlog synchronously → multi-second freezes (feed.js:131-150).
- O(n²) streaming render: full `marked.parse` + DOMPurify over the entire accumulated text on every chunk; thinking panel rebuilds innerHTML per chunk and resets scroll (chat.js:644-654,980-1013).
- `esc()` doesn't escape single quotes, and values are interpolated into single-quoted inline `onclick` JS (app.js:16-21, agents.js:168-172, tasks.js:171) — an agent dirname with an apostrophe breaks or injects.
- One malformed `tool_calls` row makes the whole resumed-history pane fail (`JSON.parse` unwrapped, chat.js:1127); double "Cancelled" banner (onError + onDone both render it); stale attachment queue survives view switches invisibly.

---

## 8. Cross-cutting themes (the patterns behind the bugs)

1. **Terminal paths are incomplete.** Almost every "stuck forever" bug (§1.3, §1.4, §1.5, §1.9, §4.8, §5.2, §5.11-waiting-tasks) is a non-happy-path exit that skips one of: update task status / flush correlation ids / notify subscribers / yield a terminal event. Consider a single `finalizeTurn(outcome)` helper that every exit path must go through, and an invariant check (no task left non-terminal when its loop exits, no drained correlation id left unanswered).
2. **"Mark delivered" happens before "actually processed".** §4.1, §4.2, §5.2, §3.9 all stem from flipping queue rows to `delivered` before the work is durably done. An ack-after-processing model (or `delivered_at` + `responded_at` with a reaper for delivered-but-unanswered rows) would eliminate the whole class.
3. **The claude-cli path lacks the protections the openai path has.** Entry lock (§1.1), mid-poll recovery (§4.1), terminal-event synthesis in chat mode (§4.8). When you fix something on one engine, diff the other.
4. **Compaction trusts character counts and persisted offsets.** Tool-pair integrity, system-prompt presence, and reset/delete invalidation must be first-class invariants of the rewrite (§2.1–2.7 are the acceptance tests).
5. **Nothing bounds output sizes.** Shell buffers, tool results, web fetches, the feed view. A single global "max bytes into context / into memory" knob with truncation markers would close §3.4, §3.6, §7.4-feed at once.
6. **Sync I/O on the request path.** `readFileSync`-based tools, `execSync` hooks, sync directory walkers — one slow disk or FIFO freezes every session in the process (§3.3, §3.15-walkers, §4.7).

## Suggested fix order

1. §5.1 (migration data wipe — anyone with an old DB loses everything), §6.1 (path traversal), §6.3+§7.2 (settings editor destroys keys).
2. §2.1/§2.2/§2.3 (compaction — fold into the planned rewrite), §5.3 (LIMIT 1000).
3. §3.1/§3.2 (grep/edit_file — two-line fixes, immediate quality win), §3.7 (stdin `/dev/null`).
4. §1.1/§1.2/§4.1/§4.2 (injection & locking — this is the cluster behind the historical "injection deadlock" commits; it's still not closed).
5. §1.3/§1.4/§1.5 + theme 1 (terminal-path completeness).
6. §4.4/§4.5 (LLM client error handling + abort), §3.4/§3.5/§3.6 (shell/output bounds).
