| Age | Commit message (Collapse) | Author |
|
recoverScheduleEntry)
Side-effect-free module so missed-wake recovery and rescheduling can be
unit-tested without booting Hono or touching SQLite.
- nextDailyAfter: advances by 24h increments until strictly > now (handles
multi-day gaps in a single step instead of looping a day at a time).
- recoverScheduleEntry: classifies a past next_wake_at into 'fire now,
then advance' vs 'silently advance' based on MISSED_WAKE_GRACE_MS (2h).
- CLAUDE_RESET_OFFSET_HOURS / resetHourFor: single source of truth for the
'+5h reset' display, previously hardcoded in three places.
Includes 12 unit tests covering grace boundaries, multi-day skip, custom
grace windows, and midnight wraparound.
|
|
|
|
Adds a 'Notifications (ntfy.sh)' section below 'Backend URL' with:
- Enable toggle (master switch)
- Topic URL field (with security hint: anyone with the URL can read)
- Optional auth token (password input; placeholder reflects whether one
is already stored, and a 'Clear stored token' button surfaces only when
hasAuthToken=true)
- Per-event-type checkboxes driven by the eventTypes catalog returned
from GET /notifications (so adding a new event type in core doesn't
require a frontend change)
- Save + Send test buttons, with inline success/error feedback
The component hand-mirrors the NtfyConfig shape rather than importing it
from @dispatch/core — matching the existing pattern (lib/types.ts mirrors
a few core types) to keep node-only barrels out of the browser bundle.
|
|
PermissionManager: add onPromptAdded(listener) callback. Fires exactly
once per unique pending prompt id, even when broadcastPending is called
repeatedly for unrelated mutations (e.g. another prompt resolving while
this one is still pending).
app.ts: instantiate NotificationDispatcher, attach to both AgentManager
and PermissionManager. Tab-title lookup via core's getTab so the
notifications carry human-readable context instead of raw UUIDs.
routes/notifications.ts:
- GET /notifications — current config (auth token redacted) plus
the event-type catalog and defaults
- PUT /notifications — partial update; auth token semantics are
undefined=keep, ''=clear, otherwise replace
- POST /notifications/test — sends a test notification with the current
config (rejects if disabled or topic invalid)
Tests:
- new permission-manager.test.ts covers the onPromptAdded contract
(one-fire-per-prompt, dedup across rebroadcasts, unsubscribe, listener
throws don't break siblings)
- existing routes.test.ts gets stubs for the new core notification
exports so the @dispatch/core mock stays complete
|
|
Adds a transport-agnostic NotificationDispatcher and a fire-and-forget
ntfy.sh transport (no SDK; just fetch). Configuration is persisted as a
single global JSON blob under the 'ntfy_config' settings key.
Event taxonomy (per-event toggles):
- turn-completed — assistant turn finished cleanly
- turn-error — final turn error (after all fallbacks)
- permission-required — new permission prompt was created
- agent-spawned — top-level user-agent tab spawned via 'summon'
Design:
- Single internal notify(event) interface so a future transport (email,
webhook) plugs in without changing call sites.
- attachToAgentManager + attachToPermissionManager subscribe to the
existing event streams via narrow listener interfaces (no @dispatch/api
dependency back into core).
- 5s in-memory dedupe window on dedupeKey suppresses permission re-emits.
- 10s per-request abort timeout so a hung ntfy server can't pin a worker.
- All sends are fire-and-forget: void Promise.resolve(...).catch(warn).
Tests (39 new):
- ntfy transport: URL/headers/body/auth/click, header sanitization,
per-event-type defaults, error paths.
- config: defaults, normalization tolerance, round-trip, redaction.
- dispatcher: master switch, per-event toggle, dedupe, agent/permission
hookups, top-level-only filtering for agent-spawned, dispose.
|
|
|
|
The Theme button + ThemeSwitcher modal were a header-triggered modal.
That doesn't belong in a sidebar-panel architecture, and theme picking
is a UI preference that belongs alongside the other Settings entries.
- Add a "Theme" section as the first block in SettingsPanel with the
same theme list as ThemeSwitcher.
- The localStorage key (`dispatch-theme`) and apply-on-change behavior
are unchanged, so the boot-time theme apply in App.svelte's onMount
keeps working without modification.
- Delete the now-unused ThemeSwitcher.svelte component; no remaining
importers.
|
|
New "Debug" panel option in the sidebar, grouping dev-facing actions.
Currently exposes the Copy-conversation button (ported from the old
header). Leaves room for additional debug actions without re-cluttering
the header.
The Copy action wraps `tabStore.copyConversation()` and shows a
"Copied"/"Failed" affordance for 1.5s, matching the previous header
behavior.
|
|
These move to dedicated sidebar panels (Debug panel and Settings panel
respectively) in follow-up commits. Header is now visibly cleaner: only
the Dispatch title (left), connection status indicator, and the Sidebar
toggle (right) remain.
|
|
Add a frontend store test (flagged by a Gemini review) that queues TWO
messages mid-turn and asserts they collapse into a single untagged
initiator row joined with "\n---\n" — matching the backend's joined user
turn — and that the next turn-start tags that single row. The prior test
only covered the single-message case, leaving the join logic structurally
correct but untested.
|
|
A message queued while the agent was mid-turn was only handled if it
arrived DURING a tool batch (injected as a [USER INTERRUPT]). If it
landed after the last tool call — or the turn had no tools — the agent
silently appended it to history and ended the turn with no response, so
it sat there unanswered. This affected both user-queued messages and
agent-queued ones (send_to_tab).
- agent.ts: stop the end-of-turn drain that swallowed trailing queued
messages into history. They now stay on the queue.
- agent-manager: after a CLEAN turn settles, continueFromQueue() drains
the queue and starts a fresh turn to answer it. Skipped on a
user-stopped or errored turn (queue preserved for the next send).
- Loop safety: continuation draws from the existing autoWakeBudget, so a
runaway agent<->agent chain is bounded; human sends refill it, so human
conversations are never throttled.
- dequeueMessages now tags message-consumed with reason
"interrupt" | "continuation"; the frontend collapses continuation-
consumed queued bubbles into the next turn's initiator row (avoids the
linger/dup traps documented in queue-interrupt-reconcile-edge-cases.md).
- Tests: agent (no-swallow + interrupt regression), agent-manager
(continuation, no-op when empty, user-stop preserves queue, bounded
loop), frontend (continuation bubble becomes next initiator).
- wishlist: remove the now-fixed item.
|
|
Add send_to_tab / read_tab tools so an agent can message or read another
tab by a git-style short handle (shortest unique prefix of the tab UUID,
min 4 chars), shown in the tab bar.
- core/db/tabs: resolveTabPrefix + shortestUniquePrefix (open tabs only,
LIKE-sanitized prefix matching)
- new tools read-tab.ts / send-to-tab.ts (+ tests) decoupled from the DB
TabRow via a minimal ResolvedTabRef projection
- agent-manager: unified deliverMessage routing (busy -> queue, idle ->
new turn) shared by POST /chat and send_to_tab; agent->agent auto-wake
budget (MAX_AGENT_AUTO_WAKES) to bound ping-pong loops
- summon/loader: send_to_tab + read_tab as grantable tools
- frontend: shortHandleFor + handle badge in TabBar; perm toggles
- notes: tab-comm / user-agents / todo-redesign plans
- chore: biome format fixes (debug-logger, summon.test)
Refs notes/plan-tab-comm.md
|
|
The debug-logger.ts module existed but was completely orphaned — none of
its functions had any callsites, so DISPATCH_DEBUG_LLM=1 did nothing.
Wires it in across the stack:
- llm/debug-logger.ts: add wrapFetchWithLogging() that tees SSE bodies via
TransformStream + response.clone() so we capture every chunk without
draining the body the AI SDK consumes. Redacts authorization / x-api-key
/ cookie headers in logs. Also exports nextDebugSeq() so requests and
log files share an id.
- llm/provider.ts: all 3 factories (Claude OAuth, plain-API-key Anthropic,
OpenAI-compatible) now pass fetch: wrapFetchWithLogging(globalThis.fetch).
For Claude OAuth the wrap goes on the inner base fetch so logged bodies
reflect the post-transform shape + Claude-Code session headers. Added
tabId to ProviderConfig for log labelling.
- agent/agent.ts: threads tabId through createProvider and emits
logAgentLoop / logStepLifecycle / logStreamEvent at every meaningful
point in the run loop — step start/end, tool count, every fullStream
event. All are no-ops when DISPATCH_DEBUG_LLM is unset.
- core/index.ts: re-exports the debug helpers.
- tests/llm/provider.test.ts: switch one full-object equality assertion
to property assertions so the test survives the new fetch: wrapper.
Plumbing the env var into the container required three more fixes:
- bin/up: re-export DISPATCH_DEBUG_LLM* so docker compose forwards them
(compose only forwards vars referenced in the environment: block).
Also pre-creates /tmp/dispatch/llm-debug and chowns it on first run so
the container's UID-1000 bun process can write into it without EACCES.
- docker-compose.yml: declare the debug vars on api.environment and
bind-mount /tmp/dispatch/llm-debug:/tmp/dispatch/llm-debug so logs are
inspectable from the host without docker exec.
- docker/entrypoint.dev.sh: explicitly forward DISPATCH_DEBUG_* through
the 'su -' login-shell barrier — su - resets the environment to TERM/
PATH/HOME/SHELL/USER/LOGNAME only, silently stripping everything else.
This is why the vars appeared via 'docker exec env' (which spawns a
new process inheriting the container env) but were absent from the
actual bun process's /proc/<pid>/environ.
bin/build: drop stray sudo for consistency with bin/up and bin/down.
|
|
- agent parameter is now required on summon tool
- new top_level param spawns independent fire-and-forget user agent tabs
- gated by perm_user_agent permission (UI checkbox added)
- agent definition type validation (subagent vs user-agent slug mismatch)
- context-aware error messages when agent slug not found
- read_file_slice added to summon tool's allowed tools enum
- updated and expanded summon tests
|
|
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
|
Extended thinking was gated on a hardcoded `model === "claude-opus-4-7"` check,
so newer/other adaptive models (Opus 4.8, Opus/Sonnet 4.6) fell into the classic
`thinking: { type: "enabled" }` branch. Adaptive models default thinking display
to "omitted", so no thinking was streamed — the UI showed nothing for Claude while
DeepSeek (a separate openai-compatible path) worked.
Replace the string check with a pure helper `anthropicThinkingProviderOptions`
that mirrors opencode's transform.ts detection:
- adaptive (`type: "adaptive"`) for Opus 4.7+ (version-parsed) and Opus/Sonnet
4.6 (id substring; handles dash and dot forms);
- `display: "summarized"` ONLY for Opus 4.7+ (they default to omitted and must
be forced); Opus/Sonnet 4.6 stream thinking without it;
- all other Claude models keep classic `enabled` + budgetTokens.
Pure function (no provider/streamText/network), unit-tested directly: Opus 4.8
(the reported bug), Opus 4.7, Sonnet/Opus 4.6, Opus 4.5 + dated Sonnet (enabled),
a future Opus 4.9 (proves version-parse), and effort->budget mapping.
|
|
Key Usage (plus Tasks and Cache Rate) used flex-1 + min-h-0, letting flex
shrink the panel below its content's natural height. The content wrapper was
a plain block, so inner scroll regions never got a bounded height and their
bars/lists spilled into neighbouring panels or past the window edge.
Drop the flex-1 fill entirely: every panel now sizes to its content and the
sidebar's own overflow-y-auto handles scrolling.
|
|
|
|
Move all loose root-level .md files (plans, reports, gemini reviews, incident
notes) into a single notes/ directory, and update the doc-reference breadcrumbs in
code comments/test labels to the notes/ path.
Add notes/queue-interrupt-reconcile-edge-cases.md: documents why the
queue/interrupt/turn-sealed reconcile path keeps surfacing edge cases (a catalog of
the four review-pass bugs, the no-loss/no-duplicate invariants, the recommended
membership-based reconcile refactor, and interleaving-test guidance).
|
|
per-chunk eviction
Replace the stored ChatMessage[] with a chunk-native model: tab.chunks (sealed
ChunkRow[]) + tab.live (transient in-flight turn buffer) + derived tab.renderGroups.
This enables per-chunk eviction (trimming WITHIN a large turn) and raw-chunk
pagination (loadOlderChunks), removing the whole-message eviction limitation.
Backend:
- Emit turn-start/turn-sealed around each turn; expose currentTurnId in the status
snapshot. turn-sealed fires after the durable write (status:idle fires before it).
- New GET /tabs/:id/chunks raw paginated endpoint (limit/before).
- Wrap appendChunks in a single SQLite transaction.
Frontend:
- turn-sealed drives a turn-aware reconcile that folds the sealed turn into chunks
while preserving a concurrent newer in-flight turn and pending queued messages;
deferred while the user is scrolled up.
- Stable turn-scoped render keys (${turnId}:${role}:${n}) avoid remount/flash.
Reconcile correctness (three review passes):
- preserve a concurrent newer turn when an earlier deferred reconcile flushes;
- keep optimistic queued user messages (no loss);
- turn-start backfill skips pending queued rows and tags only the turn initiator;
- bind consumed interrupt messages to the in-flight turn so they collapse on seal
(no lingering/duplicated bubble).
Tests: chat-store reconcile/eviction/pagination suite; api chunks endpoint + events.
|
|
- eviction-limitation.md: frontend eviction is whole-message, not per-chunk;
options to fully fix later.
- gemini-chunk-log-review.md: read-only review of the refactor (cache fix,
flat storage, pagination).
|
|
Replace the message-as-container model with a flat, append-only chunk log.
- chunks table (id, tab_id, seq, turn_id, step, role, type, data_json): one
row per chunk; tool_call (assistant) and tool_result (tool) are SEPARATE
rows linked by callId. Message/turn are derived groupings, not stored.
- chunks/transform.ts: DB-free explode (Chunk[] -> rows) / group (rows ->
messages), shared by backend and the browser frontend.
- Cache fix: toModelMessages segments each turn at tool-batch boundaries into
stable [assistant, tool] pairs per step, so earlier steps serialize
byte-identically across requests (kills the prompt-cache churn).
- agent-manager persists a turn's chunks on seal (once), discarding a failed
fallback attempt's partial chunks; rebuilds agent history from the log.
- GET /messages windows the log by chunk seq then groups; loadMoreMessages
merges a turn split across the window boundary by turnId.
- One-shot migration drops the legacy messages table and clears tabs;
settings/credentials/keys/usage preserved.
Full suite green (317 tests); biome, tsc, and svelte-check clean.
|
|
refactor plan
- cache-miss-report.md: root-causes the prompt-cache churn (multi-step turns
reshuffle their own wire prefix every step) with evidence and file refs
- plan-chunk-log.md: executable plan to move to a flat append-only chunk log,
fixing both cache stability and per-chunk frontend pagination
|
|
- send prompt-caching + oauth anthropic-beta headers on the Claude OAuth provider
- restructure the OAuth request body (billing header, identity split, relocate
third-party system prompt to the first user message) to match Claude Code
- apply rolling cache_control breakpoints and group a turn's tool results into a
single role:tool message for correct breakpoint placement
- emit per-step usage events (cache read/write split) and add the Cache Rate
sidebar panel
- dedup byte-identical tool calls within a single batch
|
|
On hosts where /home is a separate filesystem, the dispatch-api service
could start before /home was mounted. The API's first DB access then
failed (EACCES: mkdir '/home/tradam'), Claude account discovery silently
caught the error and left claudeAccounts empty, and -- because discovery
only ran in the constructor -- it stayed empty for the whole process
lifetime. Every Claude message then fell back to the deepseek-v4-flash /
empty-key defaults, producing a 401 'Missing API key' from OpenCode Zen.
Fixes:
- s6 run script waits (capped ~30s) for /home/tradam before exec'ing bun;
passes instantly where /home is on the root filesystem.
- systemd unit gains RequiresMountsFor=/home and After=...home.mount.
- agent-manager re-runs _refreshClaudeAccounts() on config hot-reload and
lazily on an empty cache in the Anthropic path, so a process that lost
the boot race self-heals on the next request instead of staying broken.
|
|
Replace the single-line input with a textarea that starts at one line,
grows vertically as text wraps (up to 7 lines), then scrolls. Override
daisyUI's .textarea min-height:5rem so the resting state is one line.
Enter sends, Shift+Enter inserts a newline.
|
|
tool
|
|
refreshAgentConfig (added to pick up model/key edits on send) also
overwrote the tab's workingDirectory with the agent's default cwd every
time, discarding the directory the user set via setWorkingDirectory.
Remove that line so refreshing agent config no longer clobbers the
per-tab working directory.
Also merge top-level wishlist.md into dispatch/wishlist.md and drop
the items completed today (subagent summon, stop button).
|
|
The dispatch-{api,frontend}-log run scripts invoke `s6-log -d3` (send a readiness notification on fd 3), but the service dirs lacked a notification-fd file, so s6-supervise never opened fd 3. s6-log aborted with "invalid notification fd: Bad file descriptor" (exit 100) and crash-looped, so nothing drained the producer's stdout pipe.
The API then filled its 64KB stdout pipe and blocked in write() before reaching listen(), so port 18390 never opened and the frontend could not reach the backend (the frontend survived only because it logs almost nothing).
Add notification-fd=3 to both logger service dirs and install them via PKGBUILD, matching every other logger on the system. This also makes s6-rc bring the logger up ready-first, preventing the pipe-fill race.
|
|
daisyUI's .loading class sets pointer-events:none on the spinner.
On iOS Safari, when a child inside a <button> has pointer-events:none,
the synthesized click event from touch sequences can fail to dispatch
to the parent button entirely - a well-known WebKit quirk. Since the
spinner covers ~20x20px of the button, mobile taps that land on it
are silently dropped.
Setting pointer-events:auto on the spinner lets touch events pass
through to the parent <button> correctly.
|
|
- Add POST /chat/stop endpoint on API
- Thread abortSignal from agent-manager through Agent.run() to streamText
- Thread abortSignal option through the Agent.run() signature
- Emit status:idle on stopTab() so frontend WS gets the update
- Add stopGeneration() store method on frontend tabStore
- Add stop button in ChatInput (btn-sm lg:btn-xs for mobile tap target)
- Add tests for /chat/stop endpoint
- Refactor processMessage to pass abortSignal to agent.run
|
|
synthetic invalid tool
- Removed __invalid__ tool definition, experimental_repairToolCall, and
v4-era NoSuchToolError catch block — AI SDK v6 already emits a native
tool-error stream event with the original tool name
- Added synthesizeResidualToolResults() helper to fill orphaned tool-call
IDs with isError: true results for abort/error terminal paths
- tool-error handler now break's instead of return's — lets sibling tools
execute normally via the manual executor loop
- Added final safety net after execution loop to catch any genuinely
orphaned tool-call IDs before round-tripping to the LLM
- Propagated isError through toModelMessages so error results are properly
flagged in conversation history
- Updated tests: tool-error event now continues to idle (not error), added
sibling-orphan prevention test
|
|
preserve slider selection on agent config refresh
|
|
AgentBuilder default, SubAgent mode display
- Filter summon tool catalog to is_subagent-flagged agents only
- Return fresh subagent list in error when slug not found
- Add subagent hint to system prompt when summon tool available
- Default is_subagent checkbox to true in AgentBuilder
- Fix tab-created event to include agentSlug and agentModels
- Add SubAgent read-only mode to ModelSelector with model slider
|
|
- Refresh agent config from API before sending a message so edits
in AgentBuilder (changed keyId/modelId/agentModels) take effect
immediately on existing tabs instead of using stale snapshots
- Broaden isRetryable check to also match 'usage limit' and
'exhausted' so fallback keys are actually tried on quota errors
|
|
backend pagination
Frontend keeps only a bounded window of chunks in memory (configurable via
settings slider, default 100). Older messages are evicted when at the bottom
and re-fetched from the backend on scroll-up.
- Backend: paginated GET /tabs/:id/messages with ?limit=N&before=seq
- Store: evictMessages trims oldest messages until total chunks ≤ limit
- Store: loadMoreMessages fetches next page and prepends with dedup
- ChatPanel: smart scroll hooks trigger eviction on return-to-bottom
- ChatPanel: onNearTop loads older history with scroll-position maintenance
- Settings: chunk limit slider in Memory section
- Fix: oldestLoadedSeq recalculated after eviction (pagination cursor stays valid)
- Fix: seq preserved on ChatMessage for cursor tracking
- Fix: scrolledUpTabs cleaned up on tab switch (no memory leak)
- Fix: evictMessages reads appSettings.chunkLimit directly (live updates)
|
|
units (User=%i)
The per-user systemd manager ([email protected]) fails to start on WSL
(kernel 6.6.87.2, microsoft/WSL#13186 — 'Failed to spawn executor:
Device or resource busy'), which breaks pacman's
30-systemd-daemon-reload-user.hook on install.
Changes:
- New [email protected] + [email protected] system
template units with User=%i (run as the named instance user)
- Remove old user-scope dispatch-api.service / dispatch-frontend.service
- Install to /usr/lib/systemd/system/ instead of .../systemd/user/
- Update PKGBUILD, .install hints, and bin/service to use
sudo systemctl dispatch-api@<user>
|
|
feat(summon): agent definition support; docs: cc/ research findings
- registry.ts: add normalizeForAnthropic() to strip , additionalProperties, default, nullable from zodToJsonSchema output so Anthropic doesn't silently reject tool definitions
- agent.ts: add toolChoice=auto for Claude OAuth to prevent Opus thinking forever without calling tools
- summon.ts: add agentSlug parameter, build agents catalog in description, add toAvailableAgents helper
- agent-manager.ts: wire agent definition loading into spawnChildAgent, agent model fallback
- loader.ts: export loadAgent, expandAgentToolNames, getAgentDirPaths; add getAgentDirPaths for permission gate
- agent.ts: auto-allow read-only tools in agent definition directories
- packaging/PKGBUILD: exclude ARM64 prebuilds from x86_64 package
- cc/: research findings on Claude Opus tool calling issues
- tests: loader tests, summon tool tests
|
|
tool of batch
The interrupt block embedded in a tool-result was persistent in the
assistant message history, so the imperative 'You MUST address these
before continuing' got re-evaluated as fresh on every subsequent LLM
step. Result: the model repeatedly thought about and re-acknowledged
the same interrupt 5-10+ times per chat (verified in production DB
traces — e.g. tab 4c5727aa had 11 thinking chunks quoting a single
interrupt verbatim).
agent.ts (toModelMessages): strip [USER INTERRUPT] from every tool-
result except the one in the freshest tool-batch (last chunk of the
last assistant message, which itself must be the last message). The
strip is a serialization-time transform only — this.messages, the DB
row, and the UI display all keep the full text. The LLM sees the
imperative exactly once: the step immediately after injection.
agent.ts (tool execution loop): batch queued messages across the
group's tool calls and inject them only into the LAST executable tool's
result. Previously the first tool to dequeue won; now the interrupt
lands in a single deterministic spot regardless of timing. Tool-level
handlers (run-shell/youtube/retrieve) are untouched — they still embed
their own interrupt text when they background work.
Also fix pre-existing tabs.test.ts: it referenced a getDescendantIds
function that didn't exist (added: BFS, leaf-first, cycle-safe, skips
archived) and imported bun:sqlite directly which vite couldn't resolve
(rewrote with a minimal FakeDatabase + vi.mock pattern matching the
rest of the suite).
|
|
management script
|
|
localStorage
Carries the in-app sidebar layout (which views are open and in what
order) across page reloads. Closes the natural follow-up to the tab-
restore feature in d2e2e67: tabs survive, but until now the sidebar
panels (Chat Settings / Tasks / Skills / Tools / etc.) reset to a
single default panel on every load.
Scope (explicitly bounded by the user):
- Persistence target: localStorage. Matches the precedent for UI
preferences (`dispatch-theme`, `dispatch-api-url`). Per-device
layout; no backend round-trip.
- sidebarOpen (the Header button that hides the whole sidebar
column) is NOT persisted; always starts open on every load.
- No drag-to-reorder UI added — persistence captures whatever order
the user established via the existing add/remove buttons.
Implementation:
• New `packages/frontend/src/lib/sidebar-storage.ts` — pure
functions `loadSidebarPanels(): string[]` and
`saveSidebarPanels(selected: string[]): void`. localStorage key
is `dispatch-sidebar-panels` (canonical `dispatch-` prefix).
`loadSidebarPanels` is defensive against every failure mode
(missing key, malformed JSON, non-array root, non-string entries,
empty-after-filter, localStorage.getItem throwing under
SecurityError). Returns a fresh array on every call so mutations
by the caller don't pollute the module-level default constant.
`saveSidebarPanels` swallows storage errors (quota / disabled /
SecurityError) — best-effort.
• `packages/frontend/src/lib/components/SidebarPanel.svelte`:
seed the `panels: $state` from `loadSidebarPanels().map(s => ({
id: nextId++, selected: s }))` and add a `$effect` that calls
`saveSidebarPanels(panels.map(p => p.selected))` whenever
`panels` changes. The session-ephemeral `id` field is regenerated
on every mount; only the `selected` strings round-trip.
• Existing addPanel / remove / dropdown handlers untouched — they
all reassign `panels` (`panels = [...panels, ...]`,
`panels = panels.filter(...)`, `panels = panels.map(...)`),
which triggers the new $effect. The minimum-one-panel invariant
(X button hidden on idx 0) is preserved at the UI layer and
reinforced by the loader's empty-fallback to the default layout.
Tests: 15 new in `packages/frontend/tests/sidebar-storage.test.ts` —
load with empty / valid / malformed / non-array / null / mixed-type /
empty-after-filter / throwing-getItem; save round-trip; save error
swallowing; overwrite semantics; empty-save / load-fallback; mutation
isolation.
Frontend total: 59 tests (was 44; +15). API 31, core 168 unchanged.
Typecheck clean (svelte-check 0 errors), biome clean (126 files).
Gemini code review (yolo mode, prompt-level write restriction to
report.md only): SHIP, no findings.
|
|
running in background
Implements the 'background-running agents + restore-layout-on-reopen'
feature. Full design and parallel-implementation plan in
`plan-bg-restore.md`; Gemini code review (SHIP verdict, no findings) in
`report.md`.
User-visible behaviors:
1. Browser-close keeps agents alive. If an agent is mid-stream when
the browser closes / reloads / loses the network, it continues
processing on the backend. (This was already the case in code —
agents run fire-and-forget in app.ts:77-79 — but it was previously
pointless because the UI never restored the tab to receive the
output.)
2. Layout restore on browser reopen. Every tab that existed at the
time the window was closed is restored, in original `position`
order, with full persisted message history. Tabs whose agents
finished while disconnected appear with the completed message.
Tabs whose agents are still running appear streaming live — the
in-flight assistant message is reconstructed from the backend's
in-memory `currentChunks` (sent over the wire on connect) and
accumulates new deltas as they arrive.
3. Explicit tab-close cancels + forgets. Clicking the X still
cancels the agent (existing `stopTab` in DELETE /tabs/:id) and
archives the row (`is_open = 0`), so it is not restored. No
change to that path.
The gap that the implementation closes: previously, App.svelte:onMount
unconditionally called `createNewTab()` with a fresh UUID, ignoring
every existing row in the `tabs` table. Every browser open was a
clean slate. The DB had the conversation history but no way for the
UI to discover it.
Implementation:
• New `TabStatusSnapshot` interface in
packages/core/src/types/index.ts (auto-exported via existing
`export * from "./types"`):
interface TabStatusSnapshot {
status: AgentStatus;
currentChunks?: Chunk[]; // present iff running
currentAssistantId?: string; // present iff running
}
• `agent-manager.ts:getAllStatuses()` rewritten to return
`Record<string, TabStatusSnapshot>` (was
`Record<string, AgentStatus>`). For running tabs only, attaches a
defensive shallow copy of `tabAgent.currentChunks` (the live
streaming array the per-message loop appends to) plus the DB id
of the in-flight assistant message. The defensive copy is the
consumer's to mutate. Idle / error tabs get `{ status }` only.
`GET /status` and the WS `onOpen` snapshot both pick up the new
shape automatically — neither call site changed.
• Frontend mirror of `TabStatusSnapshot` in
packages/frontend/src/lib/types.ts; `AgentEvent.statuses` variant
updated to use `Record<string, TabStatusSnapshot>`.
• New `hydrateFromBackend()` on the tab store
(packages/frontend/src/lib/tabs.svelte.ts). Sequence on app
mount:
1. Bail with 0 if `tabs.length > 0` (hot-reload idempotency).
2. GET /tabs → list of `is_open=1` rows in
`position` order.
3. GET /status → in-flight TabStatusSnapshot map.
4. GET /tabs/:id/messages for each tab in parallel via
Promise.all → persisted ChatMessage[].
5. Build the Tab objects, splicing the snapshot's live
chunks into the in-flight assistant message for every
running tab (two paths: merge into the existing DB row
with matching id, or append a fresh in-flight message
if no row matches).
6. `tabs = restored; activeTabId = restored[0]?.id ?? null;`
Every fetch is wrapped in try/catch so one tab's failure can't
destroy the whole restore pass.
• WS `statuses` handler in `tabs.svelte.ts:handleEvent` rewritten
for the new shape. Still fires `reloadTabMessagesFromApi` on the
desync case (frontend thinks running, backend says idle — the
pre-existing recovery path is preserved). When backend says
running, seeds in-flight chunks into the assistant message
matching `snap.currentAssistantId` (creating it if needed).
When backend says non-running, clears `isStreaming` on the
previous in-flight message and nulls `currentAssistantId`.
• `App.svelte:onMount` now awaits `tabStore.hydrateFromBackend()`
before deciding whether to fall back to `createNewTab()`.
Fallback condition is the doubly-defensive
`restored === 0 && tabStore.tabs.length === 0`. `wsClient.connect()`
fires in parallel with hydration — the resulting WS `statuses`
event is per-tab idempotent against the hydrated state, so there
is no race even if it arrives mid-hydration.
What was NOT done (deliberately, deferred to wishlist):
• Pre-existing inconsistency: core `AgentStatus` includes
"waiting_for_key" but frontend `TabStatusSnapshot.status` uses
only the existing 3-state pattern ("idle" | "running" | "error").
Not introduced here; mirrored the existing precedent.
• Restored tabs use defaults for `reasoningEffort`, `agentSlug`,
`agentScope`, `agentModels`, `workingDirectory` — these are not
in the DB `tabs` schema. Future schema expansion.
• Per-delta DB flushing — not needed; the in-memory snapshot
covers the gap between flushAssistant calls.
• LocalStorage cache of tab ids — backend DB is the source of truth.
Process notes:
• Implemented via parallel programmer subagents (flash agents were
requested but unavailable in this environment — substituted with
"programmer" agents, which share the "reads a plan, implements a
single step" charter). Backend (Segment A: getAllStatuses + 5
tests) and frontend (Segment B: types + hydrateFromBackend +
statuses handler + onMount + 8 tests) ran disjoint-file-ownership
in parallel.
• Gemini code review (yolo mode for tool access, explicit
prompt-level write restriction to `report.md` only) returned a
SHIP verdict with no findings against the plan.
• Self-review surfaced one followup gap that Gemini's earlier
plan-mode pass also caught: no explicit test for
`/tabs/:id/messages` failure isolation. Added a test covering
both HTTP-500 and network-error variants alongside a healthy
tab, asserting per-tab failures don't destroy the whole restore.
Tests:
• api/tests/agent-manager.test.ts: +5 (snapshot empty record,
idle-tab field omission, running-tab field inclusion, defensive
copy invariant, omits chunks for running tab with null
currentChunks). 31 total (was 26).
• frontend/tests/chat-store.test.ts: +9 (restore-with-messages,
in-flight seeding, /tabs failure → 0 returned, empty /tabs
array, idempotency when tabs already exist, idle-status when
/status omits, running-snapshot statuses handler seeding,
idle-snapshot statuses handler clearing, per-tab failure
isolation across HTTP-500 and network-error). 44 total (was 35).
Totals: 243 tests across 3 packages all green; typecheck clean on
core + api + frontend; biome clean across 124 files.
|
|
switches preserve chat history
Before this change, swapping the model mid-conversation via the sidebar
slider lost all prior turns: the new model saw only the current user
message and treated the conversation as brand-new.
Root cause: `getOrCreateAgentForTab` invalidates the cached Agent
(`tabAgent.agent = null`) whenever the effective keyId, modelId,
permission key, or working directory differs from the cached values.
The replacement Agent was then constructed with `messages: []` and
the post-construction step that loads prior turns from the SQLite
`messages` table simply did not exist. `processMessage` had already
appended the current turn's user message to the DB (line 960) before
calling `getOrCreateAgentForTab` (line 1015), so the DB held the full
context — it was just never read.
Fix: after every `new Agent(...)` in `getOrCreateAgentForTab`, call
`getMessagesForTab(tabId)`, walk backwards to the most recent user-role
row, and assign all strictly-prior rows to `tabAgent.agent.messages`.
The walk-backwards strategy correctly handles two boundary cases:
1. Simple model switch — last DB row is the current user message;
drop it (`Agent.run()` will push it again at line 546).
2. Agent-mode auto-fallback retry — last DB row may be a partial
assistant response flushed by the previous failed attempt; we
drop both that and the current user message in one pass.
System-role rows (config-reload notices, etc.) are preserved verbatim;
`toModelMessages` already strips them before the wire payload, so this
is safe.
The fix covers every Agent-reconstruction trigger, not just the model
slider:
- Sidebar model/key change (the reported case)
- Permission setting change
- Working-directory change (`processMessage` line 951)
- dispatch.toml config-watcher reload (lines 236–237)
- Skills directory watcher reload (lines 249–250)
- `stopTab` after user cancellation (line 775)
If `getMessagesForTab` throws (e.g. DB locked, schema mismatch), we
swallow the error and leave `messages: []` — matching pre-fix
behaviour for that case so this commit never regresses.
Tests (+6 in `packages/api/tests/agent-manager.test.ts`, total 26):
- pre-populates Agent.messages from DB history
- leaves messages empty when DB has only the current turn (first msg)
- excludes a partial assistant trail from a prior fallback attempt
- preserves system-role rows in pre-populated history
- survives a getMessagesForTab failure without crashing
- reloads history on every Agent reconstruction (simulated slider
switch from Opus to DeepSeek across two processMessage calls)
The test rig was extended with:
- `fakeMessagesByTab` map + `setFakeMessages` helper letting tests
inject arbitrary DB rows for the mocked `getMessagesForTab`.
- `constructedAgents` array captured at `run()` entry (not at
construction) so each test sees the post-pre-populate snapshot;
the production code reassigns `agent.messages` after `new Agent()`
returns, so capturing at construction yielded a stale empty array.
- Pluggable `runImpl` hook for tests that want a custom event stream
(not yet exercised; staged for the next round of agent-mode
fallback tests).
Totals: 229 tests across 3 packages all green; typecheck clean on
core + api + frontend; biome clean across 124 files.
|
|
reasoning round-trip + max-thinking budget audit
Migrates the LLM stack from [email protected] + @ai-sdk/[email protected] +
@ai-sdk/[email protected] to [email protected] + @ai-sdk/[email protected]
+ @ai-sdk/[email protected]. Full design in plan-v6-upgrade.md;
two rounds of Gemini code review captured in report.md.
Motivation: the recurring 'reasoning-signature without reasoning' error
on Claude Opus 4.7 was a v4 SDK artefact — @ai-sdk/[email protected] emitted
Anthropic signature_delta as a separate stream chunk that orphaned when
the model produced a signed-but-empty thinking block, and our chunk
store had no signature field so the round-trip back to Anthropic was
rejected on the next turn. In v6, signatures arrive inside
providerMetadata on the reasoning-end event, and the orphan-signature
class of bug is gone at the SDK level.
Core changes:
• ThinkingChunk gains optional metadata?: Record<string, unknown>
(the v6 providerMetadata blob). A non-undefined metadata 'seals'
the chunk: subsequent reasoning-delta opens a new chunk rather
than extending the sealed one.
• AgentEvent gains { type: 'reasoning-end'; metadata? } (replaces
the v4 reasoning-signature variant).
• toModelMessages (replaces toCoreMessages):
- returns ModelMessage[] (was CoreMessage[])
- thinking → { type: 'reasoning', text, providerOptions: metadata }
- tool-batch entries → { type: 'tool-call', input } (was 'args')
- tool results → { output: { type: 'text', value } } ToolResultOutput
• Claude OAuth uses createAnthropic({ authToken }) natively — no more
custom-fetch x-api-key → Bearer swap.
• rewriteBodyForOpus47 deleted — Opus 4.7 adaptive thinking is native
via providerOptions.anthropic.thinking = { type: 'adaptive' }.
• V1 middleware → V3 (specificationVersion: 'v3').
• v4-era normalizeMessages openai-compatible middleware deleted; the
v6 openai-compatible provider extracts reasoning_content natively
from { type: 'reasoning' } content parts.
• applyAnthropicStructuralNormalisations (mirrors opencode
provider/transform.ts:53-148): drops empty text/reasoning parts,
scrubs non-[a-zA-Z0-9_-] toolCallIds, splits [tool-call, non-tool]
assistant turns (Anthropic rejects tool_use followed by text).
• applyOpenAICompatibleReasoningNormalisation (mirrors opencode
transform.ts:217-249): lifts reasoning text into
providerOptions.openaiCompatible.reasoning_content (always, even
empty). Solves DeepSeek 'The reasoning_content in the thinking
mode must be passed back' — the v6 SDK skips emitting
reasoning_content when text is empty (dist/index.mjs:245), but
DeepSeek requires the field present once thinking was used.
• Tools: tool({ inputSchema: jsonSchema(zodToJsonSchema(...)) })
(was parameters: ZodSchema). AI SDK tools have no execute
callback — the agent runs tools manually for permission prompts
and shell-output streaming. New dep: zod-to-json-schema@^3.25.2.
• fullStream event loop rewritten for v6 event shape: text-delta
(text not textDelta), reasoning-start/delta/end, tool-input-*,
tool-call (input not args), tool-result, tool-error (new), abort
(new), start-step/finish-step, finish.
Max-thinking audit (matches opencode transform.ts:642-671 budgets):
• Claude enabled-thinking max budget 16000 → 31999 (Anthropic ceiling)
• Claude enabled-thinking high budget 10000 → 16000
• maxOutputTokens 'budget + 8000' → fixed 32000 (matches opencode's
OUTPUT_TOKEN_MAX; model self-allocates thinking vs response within)
• Opus 4.7 adaptive thinking gains display: 'summarized' and sibling
effort field (without these, thinking content is hidden by Anthropic
and the model barely thinks).
Frontend mirrors:
• types.ts — ThinkingChunk.metadata?, AgentEvent reasoning-end
• tabs.svelte.ts — routes reasoning-end through applyChunkEvent
• ChatMessage.svelte — hides empty thinking chunks; hides the entire
assistant bubble when no chunk has renderable content
Gemini-review-driven fixes:
• tool-error and abort stream events now surface as error chunks
(were silently ignored)
• toolCallId scrubbing pass (opencode transform.ts:96-122 parity)
• Empty-reasoning-cull explicit test coverage for both Anthropic
structural normalisation and DeepSeek path
Test counts (223 tests across 3 packages, all green):
• tests/chunks/append.test.ts: 44 (was 38) — reasoning-end sealing,
orphan walk-back, multi-block interleaving
• tests/agent/agent.test.ts: 24 (was 5) — exhaustive v6 event
mappings, structural normalisations, signature/reasoning_content
round-trip, tool-error/abort branches, DeepSeek scenario, empty
reasoning edge case
• tests/llm/provider.test.ts: 9 (was 22) — dropped 13 obsolete v4
middleware tests; new minimal tests confirm no middleware wrapping
on default openai-compat path and that createAnthropic gets
authToken vs apiKey correctly for OAuth vs api-key flows
• tests/tools/registry.test.ts: 10 (was 4) — v6 tool() contract
(inputSchema, no execute, JSON Schema for nested zod)
• packages/api/tests/agent-manager.test.ts: 12 (was 7) — mock Agent
emits v6 reasoning events; reasoning-end broadcast + ordering
• packages/frontend/tests/chat-store.test.ts: 35 (was 32) —
reasoning-end flow through Svelte $state store
typecheck clean (tsc --noEmit on core + api, svelte-check on frontend),
biome clean across 124 files.
|
|
|
|
|
|
separate dispatch-electron, add bun-based frontend serve
PKGBUILD is now a split package producing three .pkg.tar.zst files in one
makepkg run:
- dispatch base application files (/opt/dispatch), CLI wrappers
(dispatch-api, dispatch-frontend), env configs
(/etc/dispatch/dispatch-api.conf,
/etc/dispatch/dispatch-frontend.conf)
- dispatch-systemd systemd user units for dispatch-api + dispatch-frontend
(conflicts with dispatch-s6)
- dispatch-s6 s6-rc service pipelines (-srv + -log) for both services
(conflicts with dispatch-systemd)
The Electron desktop wrapper moved to its own self-contained PKGBUILD at
packaging/electron/, producing dispatch-electron. It bundles its own copy of
the frontend dist + electron entry points under /opt/dispatch-electron and
does not depend on the base dispatch package, so users can install it
standalone and point VITE_API_URL at any backend at build time.
Files under packaging/ are now read directly from ${_projectdir}/packaging/
rather than via source=(); the two s6 service dirs share basenames (run,
type) which would collide inside ${srcdir}.
New frontend static server: packages/frontend/serve.ts uses Bun's built-in
HTTP server (no extra runtime deps) with SPA fallback to index.html and
path-traversal protection. PORT/HOST/DIST_DIR overridable via env. Exposed
as 'bun run --cwd packages/frontend serve' and via /usr/bin/dispatch-frontend.
Build scripts:
- bin/build-pkg now prints the freshest built packages
- bin/install-pkg installs dispatch + dispatch-systemd by default;
accepts package names or --all; searches both
packaging/ and packaging/electron/
- bin/build-pkg-electron new, builds the electron split
- bin/build-pkg-windows replaces bin/windows-pkg; drops the hard-coded
WSL copy step, just prints the win-unpacked path
.gitignore extended to cover packaging/*.tar.zst and packaging/electron/{src,pkg,*.pkg.tar.zst,*.tar.zst}.
|
|
|
|
preventing silent fallback to OpenCode Go endpoint
|
|
createTabStore + handleEvent (replaces POJO harness)
|