| Age | Commit message (Collapse) | Author |
|
|
|
# Conflicts:
# packages/api/src/agent-manager.ts
# packages/api/tests/agent-manager.test.ts
# packages/frontend/src/lib/tabs.svelte.ts
|
|
Root cause of the 'first warmup misses' + 'switch to chat misses' bugs:
Anthropic keys the MESSAGE-level prompt cache on `tool_choice` AND the
extended-thinking parameters (both rows in their cache-invalidation table mark
the messages cache as invalidated on change). The original warmCache() sent
toolChoice:'none' and NO thinking providerOptions, while real turns send
toolChoice:'auto' + thinking config for the effort. So warming and chat wrote
TWO different message-cache buckets:
- warmup #1 missed (no warm-only bucket existed yet), every later warmup hit
its own bucket;
- the next real chat message read the OTHER bucket → miss.
Fix: extract a shared buildStreamOptions() that produces the cache-affecting
params (toolChoice + thinking providerOptions + maxOutputTokens). Both run()
and warmCache() now call it with the SAME resolved reasoning effort, so the
warming replay refreshes the exact cache the next real message reads. The
trivial probe turn is still appended AFTER the last cache breakpoint, so it
never disturbs the cached prefix.
Threaded the per-tab reasoning effort (per-model -> per-tab selector -> default,
mirroring processMessage) from the frontend resolver through POST /chat/warm to
warmCacheForTab to warmCache.
Tests: updated the warmCache toolChoice test to assert it MATCHES a real turn,
added an invariant test driving run() and warmCache() and asserting identical
cache-affecting params, and assert effort forwarding in the frontend store.
check / test (780) / frontend build / typecheck all green.
|
|
Keep a tab's provider prompt-cache warm while idle by periodically replaying
the exact cached conversation prefix plus a single trivial throwaway turn,
resetting the provider's ~5-min cache TTL so the user's next real message hits
a warm cache.
Backend:
- Agent.warmCache(history): extracts buildLlmContext() shared with run(), then
re-sends the identical system+tools+history prefix (same Anthropic
cache_control breakpoints) plus a 'reply with just a .' probe turn via
toolChoice:none. Returns the request usage; mutates no history, emits/persists
nothing.
- AgentManager.warmCacheForTab(): resolves the same agent the next real turn
would use, replays the FULL genuine history, refuses while a turn is running.
- POST /chat/warm: returns ONLY the warming request's usage (never persisted,
never folded into the real usage aggregate).
Frontend:
- cache-warming.svelte.ts store: per-tab 4-min repeating idle timer with
countdown, warming-specific last-request cache %, and error capture. Arms on
turn end, pauses during a turn, disables+resets on a real user message.
- cache-warm-storage.ts: per-tab localStorage persistence of the toggle.
- Lifecycle hooks wired into tabs.svelte.ts (status/statuses/sendMessage/
hydrate/create/open/close).
- ModelSelector: bottom-of-panel checkbox + debug strip (last-% / countdown /
error), shown only when enabled. Warming cache data never touches the real
Cache Rate view.
Tests: core warmCache (5), api warm route (3) + warmCacheForTab (3), frontend
store (12) + storage (10). check / test (779) / frontend build / typecheck all
green.
|
|
# Conflicts:
# packages/frontend/src/lib/components/ChatInput.svelte
|
|
Summarize a conversation's older "head" into a structured anchored Markdown
summary while preserving the most recent turns verbatim, shrinking context size
while keeping the information needed to continue coherently. Triggered by a
"Compact conversation" button in Chat Settings (not an agent tool).
Approach informed by OpenCode's session/compaction.ts:
- Ported SUMMARY_TEMPLATE (Goal / Constraints / Progress / Key Decisions /
Next Steps / Critical Context / Relevant Files) and the anchored-summary
buildPrompt (re-summarizes a prior summary when present).
- Ported the TOOL_OUTPUT_MAX_CHARS (2000) cap on tool results in the summary
request.
- Simplified tail selection to a fixed recent-turn count (DEFAULT_TAIL_TURNS=2)
instead of OpenCode's token-budget splitTurn.
core:
- New src/compaction/ module (pure, DB-free): template, prompt builder,
head/tail selection, transcript renderer with tool-output capping, prior
summary extraction. Generic over ChatMessage so callers keep turnId/seq.
- db/chunks.ts: rekeyChunks(from,to) relocates a tab's full history to a
backup tab (reversible — nothing is deleted).
- AgentEvent: compaction-started / -complete / -error variants.
api:
- AgentManager.compactTab(tempTabId, sourceTabId): side-effect-free
resolveConnection() for the compactor model (configured compaction_model_*,
else the source tab's own key+model), one-shot tool-less summary generation
via a transient Agent, then relocate full history to a fresh backup tab and
re-seed the canonical source id with [summary turn + preserved tail]. Source
tab is locked (messages queue) during the run; queue drains afterward.
- Routes: POST /tabs/:id/compact, GET/PUT /tabs/settings/compaction-model.
frontend:
- "Compact conversation" button in ModelSelector (Chat Settings), between
Working Directory and the agent toggle; idle-gated.
- Compaction-model key+model selector in Settings, beside the title model.
- Transient placeholder tab shows a large, non-faded "Please wait, compacting
conversation…" screen; closing it cancels. Source input locked while running.
- Handle compaction-* events: reload compacted source, insert backup tab,
refocus source, discard placeholder.
tests: core compaction unit tests, rekeyChunks DB test, AgentManager.compactTab
orchestration tests, and compaction route tests. All green (713 tests), biome
clean, all typechecks pass, frontend builds.
|
|
|
|
|
|
|
|
Use DaisyUI's status-with-ping pattern on the tab status dot so it pings
when the agent has stopped and is likely waiting on the user:
- idle with incomplete (pending/in_progress) tasks remaining, or
- stopped due to an error.
Implements wishlist item #21.
|
|
Add multimodal image/PDF input to the chat box via clipboard paste, gated by a
graceful per-model capability check.
UX: a pasted image/PDF inserts an inline token (【image:…】 / 【pdf:…】) into the
draft, so attachments have ORDER relative to typed text and can be referenced
positionally. The token is the only handle — deleting it (atomic Backspace/
Delete, or selection overlap) detaches the file; an input-reconciliation safety
net detaches any attachment whose token is no longer intact. No preview strip.
Capability check: resolveModelCapabilities reads models.dev modalities.input
(new GET /models/capabilities, mirrors /context-limit). The input blocks Send
(no tokens spent) only on a definitive 'no'; unknown capability (catalog offline
/ unmapped provider) stays permissive. Attachments require a fresh turn — Send is
blocked while generating and /chat rejects content mid-turn (409).
Attachments are EPHEMERAL: forwarded to the model for the turn via ordered AI SDK
ImagePart/FilePart content, but never persisted (history keeps the text with
[image]/[pdf] markers). Text-only turns serialize byte-identically to before.
Limits (Anthropic-aligned, enforced at paste + re-validated server-side):
PNG/JPEG/WebP/GIF/PDF; image ≤5MB, PDF ≤32MB, ≤20 attachments, ≤32MB total.
core: UserContentPart types, models/attachments validator, capability resolver,
agent.run+toModelMessages thread ordered content. api: /chat content validation +
passthrough. frontend: attachment-tokens helper, ChatInput paste/token/gating,
per-tab staged attachments, App.svelte capability fetch. +44 tests.
|
|
Adds an agent-callable `key_usage` tool that reports current usage for
configured API keys so the agent can pick a key with headroom, warn before
hitting a rate limit, and diagnose exhausted-key failures.
Per key it reports: provider, active/exhausted status (with last error +
when it was exhausted), remaining rate-limit headroom and reset timestamp per
window (5-hour, weekly, and monthly where the provider exposes it), and
whether the figures are live or served from cache (with the cache's
last-fetched-from-source time). Supports anthropic and opencode-go keys
(live with cache fallback for anthropic; live scrape for opencode-go).
Optional `key_id` reports one key; omitted reports all.
Hard permission gate `perm_key_usage` (default off): when disabled the tool
is completely removed from the toolset/context. Registered in both the
parent permission-gated path and the child whitelist path, advertised in the
system prompt (TOOL_DESCRIPTIONS), grantable to subagents via the summon
enum, and exposed as a frontend tool-permission checkbox.
To report data freshness, claude.ts gains `getAccountUsageWithSource` +
`ClaudeUsageResult` (live vs cache + cachedAt from usage_cache.cached_at);
the existing `getAccountUsage` now delegates to it, preserving behavior.
Tests: core key-usage tool suite (windows, %-conversion, freshness, exhausted
status, unsupported/unavailable, filtering) + agent-manager perm-gate test.
|
|
# Conflicts:
# packages/api/src/agent-manager.ts
# packages/api/tests/agent-manager.test.ts
# packages/frontend/src/lib/components/ToolPermissions.svelte
# packages/frontend/src/lib/settings.svelte.ts
|
|
Add 'LSP queries' to the ToolPermissions UI (disabled by default, matching
the backend's perm_lsp gating). The existing generic settings route
(/tabs/settings/perm_lsp) and agent-manager invalidation handle persistence
and agent rebuild automatically — only the frontend store default and the
permission entry needed wiring.
|
|
Add a dedicated, permission-gated search_code tool that wraps boyter/cs
(code spelunker) — a fast, relevance-ranked, structure-aware code search
engine — giving agents a better default than grep/find for exploratory
'where is X / how does Y work' searches (ranked results, snippets, ~5x
smaller payloads).
- packages/core/src/tools/search-code.ts: createSearchCodeTool factory;
-f json invocation, workdir path containment, graceful missing-binary
handling (DISPATCH_CS_BIN override), readable per-file formatted output.
- Wire-up: export from core; register in agent-manager (both child-whitelist
and parent perm paths) behind new perm_search_code; add to summon catalog
+ tools enum; frontend ToolPermissions + settings.
- Docker: build a patched, statically-linked cs (pinned v3.1.0 commit) in a
golang builder stage and bundle at /usr/local/bin/cs.
- docker/cs/luau-declarations.patch: additive Luau declaration table so
--only-declarations / definition ranking works for Roblox .luau files
(upstream has Lua but not Luau). Applied during the Docker build.
- Tests: new search-code.test.ts (stubbed JSON formatting + live-cs
integration, skipped when cs absent); agent-manager/routes mocks +
perm-gating assertions; loader pass-through.
All tests (596), biome, and tsc (core/api/frontend) pass. cs-builder Docker
stage verified to build and produce a working patched binary.
|
|
Apply tab-active so the pinned + button uses the same raised base-100
fill, border and top-corner treatment as a selected tab, and drop its
left border (!border-l-0) so it sits flush against the bar's edge.
Keep !rounded-ss-none for the square top-left corner.
|
|
daisyUI tabs-lift rounds both top corners; the pinned + button sits at
the bar's left edge, so its top-left (start-start) radius reads as a
stray rounded corner. Override with !rounded-ss-none.
|
|
Pin the new-tab + button to the left edge of the user-tab row via
position: sticky (!sticky left-0 z-10) with an opaque bg-base-200 and a
right-side shadow as a floating cue, so it stays reachable at any
horizontal scroll. Add a flex-fill trailing pad after the last tab
(flex-1 min-w-12) that opens a new tab on double-click.
|
|
|
|
Replace the imperative id-based CRUD todo tool (add/update/list/get/remove)
with opencode's declarative whole-list design: a single `todos` param that
replaces the entire list each call. No model-visible ids, no delta reasoning,
no "task not found" spirals.
- core: TaskItem { id, content, status }; statuses pending|in_progress|
completed|cancelled. TaskList.setTasks/getTasks/onChange. New rich
TODO_DESCRIPTION adapted from opencode's todowrite.txt.
- api: TASK_MANAGEMENT_GUIDANCE system-prompt section (from anthropic.txt);
updated TOOL_DESCRIPTIONS.todo. Reload fix: TabStatusSnapshot now carries
per-tab tasks so getAllStatuses rehydrates the panel on reconnect.
- frontend: mirror types; hydrate tasks from snapshot in both restore paths;
upgrade sidebar Tasks panel to render content + all four statuses + progress.
- tests: new core task-list.test.ts (15); updated api TaskList mocks +
getAllStatuses task-snapshot coverage.
bun run check clean; 569 tests pass; all packages typecheck.
|
|
# Conflicts:
# packages/frontend/src/lib/components/ChatInput.svelte
|
|
- TabBar: HTML5 drag-and-drop to reorder user tabs (subagent tabs untouched);
double-click a tab title to rename (Enter/blur confirm, Escape cancel).
- Store: add reorderTabs/renameTab/setDraft; per-tab in-memory `draft` and
`manualTitle` fields. Manual rename suppresses first-message auto-title.
- ChatInput: bind to the active tab's draft so switching tabs saves/restores
unsent text instead of clobbering it.
- Backend: updateTabPositions() + PATCH /tabs/reorder persist tab order to the
existing `position` column; tabs without a stored position fall to the end
then get explicit positions on first reorder.
- Tests: store reorder/rename/auto-title-guard/draft coverage; core
updateTabPositions coverage (FakeDatabase extended with transaction support).
|
|
|
|
Restructure ChatInput into two stacked bars:
- Top bar: auto-resizing textarea + fixed-width send/stop button that
morphs in place (no layout shift) across idle/generating states.
- Bottom bar: agent status icon, context-window fill bar, and compact
token count + percent (inert bar when model max is unknown).
Wire contextLimit prop from App.svelte into ChatInput, reusing the
shared computeContextUsage helper so it agrees with the sidebar.
|
|
|
|
# Conflicts:
# packages/api/tests/agent-manager.test.ts
|
|
Cross-branch contract test (u2/context-window-view merged from dev): the
Context Window panel derives current context from cacheStats.last via
computeContextUsage. This drives the full path — persisted usage aggregate ->
hydrateFromBackend -> cacheStats.last -> computeContextUsage -> '48,200 /
200,000' — proving the view shows real context size immediately after a reload
on a new device (not 'No context data yet'). Guards the contract so neither
persistence nor the view can silently break it.
|
|
|
|
Addresses the live-accumulator overshoot a Gemini review surfaced: the
frontend adds every streamed usage event to cacheStats, but a rate-limited
fallback attempt's usage is discarded server-side (never persisted). Live
numbers overshot until a reload re-seeded from the DB aggregate.
Fix: turn-sealed (emitted AFTER the atomic usage-row write) now carries the
authoritative getUsageStatsForTab aggregate. The store REPLACES (not adds)
cacheStats with it every turn — landing the just-sealed turn's usage AND
self-healing any live drift, including the discarded-fallback overshoot. No
extra round-trip (piggybacks turn-sealed); idempotent in the happy path.
- core: add UsageStats type; getUsageStatsForTab returns it; turn-sealed gains
optional usageStats field.
- api: agent-manager reads getUsageStatsForTab post-flush and attaches it to
the turn-sealed emit (try/catch: omit on DB error).
- frontend: turn-sealed handler replaces cacheStats (undefined ⇒ untouched
back-compat; null ⇒ clear).
Tests: frontend reconcile/self-heal/back-compat/null-clear; api turn-sealed
carries aggregate. 509 -> 514 passing; typecheck + biome green.
|
|
Add a 'Context Window' sidebar view showing the live context occupancy
(latest request's input+output) against the model's maximum context
window, resolved dynamically from the models.dev catalog.
- core: models.dev catalog module (resolveContextLimit) with disk cache,
TTL, stale-fallback + offline penalty memo; null for unknown models.
- api: GET /models/context-limit?provider=&modelId=.
- frontend: ContextWindowPanel + computeContextUsage helper; App resolves
+ caches the active model's max (anthropic/opencode-anthropic only);
percent shown to 2 decimals; degrades to bare token count when max
unknown.
- tests: core catalog (13), api route (3), frontend helper (6).
|
|
Address two UI-accuracy issues found in review:
- AgentBuilder: the per-model effort select no longer disguises an unset
value as 'High'. Adds an explicit 'Inherit' option; choosing it strips
the effort key so the saved TOML omits it (and the call site falls back
to per-tab → default), matching displayed intent to persisted state.
- ModelSelector: effort badges for models without an explicit override now
reflect the actual effective effort (per-tab selector → default) instead
of always showing the default constant, mirroring backend resolution.
|
|
Persist usage as invisible type:"usage" chunk rows (side channel):
- core: add "usage" ChunkType + UsageData; exclude usage rows from
getChunksForTab/getTotalChunkCount; add getUsageStatsForTab aggregate
(exported from barrel); defensive skip in groupRowsToMessages.
- api: agent-manager accumulates per-attempt usageRows and flushes them in
the same atomic appendChunks call as the turn's content (discarded on a
superseded fallback attempt). GET /tabs enriches rows with usageStats.
- frontend: hydrateFromBackend seeds cacheStats from usageStats (reload only;
no re-seed on statuses reconnect, so no double-count with live events).
Tests: core DB-backed usage persistence/aggregate; api usage-row-per-event +
fallback discard; routes GET /tabs usageStats; frontend hydrate seed +
no-double-count + live-accumulation-after-seed. 495 -> 509 passing.
|
|
Add a per-model/key reasoning effort setting to agent definitions,
surfaced and editable in the Agent Settings page and displayed at a
glance in the model selector views.
- core: single source of truth for effort levels (REASONING_EFFORTS,
DEFAULT_REASONING_EFFORT='high', labels, isReasoningEffort guard);
add 'xhigh' level; AgentModelEntry.effort; xhigh budget=24000 for
classic-thinking Claude; default floor 'high'. Persist/parse effort
in the agent TOML loader.
- api: thread effort through the fallback chain with per-model -> per-tab
-> default precedence; validate /chat + agentModels effort from the
canonical list.
- frontend: effort <select> per model row in AgentBuilder; effort badges
in ModelSelector (agent + subagent chains); Thinking dropdown sourced
from canonical list; per-tab default raised to 'high'.
- tests: +15 (loader round-trip, agent xhigh budget, canonical list +
guard, api precedence, route validation).
|
|
- Add phosphor-svelte ^3.1.0 to frontend deps.
- Wire phosphor-svelte/vite (sveltePhosphorOptimize) as a fallback so
stray named imports still tree-shake correctly. Per-icon imports like
'phosphor-svelte/lib/ListIcon' remain the preferred pattern; see the
comment in vite.config.ts.
- Replace the 'Sidebar' text in Header.svelte with a Phosphor List icon
styled as a square daisyUI button (btn btn-square btn-sm btn-neutral)
with aria-label preserved for screen readers.
|
|
Brings in the n2/ntfy-notifications feature (ntfy.sh push notifications
with per-event toggles, subagent-suppression flag, topic-only input,
Settings UI, dispatcher + transport + config modules, 12+ new tests),
the header declutter (theme picker + Debug panel moved into Settings /
sidebar), the shared theme boot-apply module, and an a11y label for the
remove-panel button.
No code changes from this branch were touched by the merge — the
overlap was purely textual.
Conflict resolution:
1. HANDOFF.md (add/add conflict). Both branches independently put a
single-purpose HANDOFF.md at the repo root for their respective
in-flight feature, matching the existing convention (c351719 did
the same for this branch; 29bdd00 did the same for ntfy). After
this merge both features ship, so neither is in-flight anymore.
Archive both into notes/:
- notes/wake-schedule-handoff.md (this branch — git tracks as a
rename from HANDOFF.md)
- notes/ntfy-notifications-handoff.md (dev — recovered from
MERGE_HEAD before deletion)
The root HANDOFF.md is intentionally absent post-merge; the next
in-flight branch will create its own.
2. packages/api/tests/routes.test.ts (auto-merged). dev appended ntfy
stubs to the vi.mock('@dispatch/core', ...) factory; this branch
appended a 'Wake schedule routes' describe block at the bottom.
The two regions don't overlap and the textual auto-merge is correct
(verified: 6 describe blocks, both mock-stub regions and the new
describe present, no conflict markers).
Verification on the merge commit:
bun run test → 31 files, 495 / 495 passing
(was 431 on the branch + 64 from dev)
bun run check → biome clean, 156 files
bun run --cwd packages/frontend typecheck
→ svelte-check 0 errors, 0 warnings
dev can now fast-forward to this commit:
git checkout dev && git merge --ff-only r1/claude-reset-fix
|
|
The marked-hour summary badge ('11 AM', '12 PM', etc.) wrapped after
the hour number when the trailing 'Probes …' text pushed against it,
so '11' sat on the first line and 'AM' dropped to the second — ugly
two-line badge.
Add 'whitespace-nowrap' (prevents the space between hour and meridiem
from breaking) and 'shrink-0' (so the badge never gets compressed
narrower than its content) to the badge span.
|
|
Round-2 Gemini review found that the SnapshotSequencer's 'most-recent
client seq wins' rule only protects against RESPONSE reordering. If the
network reorders the REQUESTS themselves (B reaches the server before
A), the server's snapshot reflecting the true final state may carry the
older client seq and get discarded — UI permanently desyncs.
Two related fixes:
1. Replace pendingHours: Set<number> (per-hour lock) with a single
pendingHour: number | null (global mutation lock). All 24 toggle
buttons go disabled while any POST is in flight. This serializes
mutations on the wire, eliminating the request-reorder failure mode
entirely.
2. Send the action explicitly. toggleHour now derives 'on' or 'off' from
its local state and passes it to postToggle, which sends it on the
wire. Pairs with the matching backend contract change — the server
no longer guesses from its own state, so even if a stale UI made it
through it would just be an idempotent no-op or timestamp refresh
instead of an inverted click.
The SnapshotSequencer is retained — it still guards the GET-on-mount
vs first-click race (where the two requests are NOT both mutations and
the global lock doesn't apply).
UX note: per-hour 'cursor: wait' visual is preserved for the hour whose
request is in flight (so the user can see which click is pending),
while the OTHER hours go merely disabled (no cursor change) — a clearer
'busy' signal than dimming everything uniformly.
svelte-check: 0 errors, 0 warnings. 431 / 431 tests pass.
|
|
Brings in: theme picker consolidation, sidebar Debug panel,
header declutter, a11y label on remove-panel button. Conflicts
in SettingsPanel.svelte (theme picker insertion site overlaps the
ntfy block) and HANDOFF.md (each branch maintains its own).
# Conflicts:
# HANDOFF.md
|
|
The Settings field is now a plain topic name (e.g. `my-secret-topic`)
instead of a full URL. The transport always posts to
`https://ntfy.sh/<topic>` (URL-encoded), and the only server-side check
is "non-empty when enabled". Removes the user-visible
"string does not match the expected pattern" error people hit when
typing a bare topic.
- packages/core/src/notifications/ntfy.ts: drop validateTopicUrl;
add buildNtfyUrl(topic) + exported NTFY_BASE_URL.
- packages/core/src/notifications/types.ts, config.ts: rename
topicUrl -> topic; update docs.
- packages/api/src/routes/notifications.ts: only validates non-empty
topic when enabled. Also fixes a latent bug where notifySubagents
was dropped on every PUT (was not passed to normalizeNtfyConfig).
- packages/frontend/src/lib/components/SettingsPanel.svelte: relabel
field "Topic URL" -> "Topic"; placeholder "your-secret-topic";
updated helper copy.
- Tests updated: rewrote validateTopicUrl coverage as buildNtfyUrl
coverage + proof that previously-rejected topics (dots, spaces,
unicode, "Any Topic Whatsoever") now POST cleanly.
- HANDOFF.md: added a short "topic-only input" section.
|
|
Replaces the per-hour inFlightSeq with a single shared SnapshotSequencer
used by both loadFromServer() and postToggle() (Gemini #2, High; nit #4).
The bug: applySnapshot replaces the *whole* schedule object. The old
per-hour counter could not stop request A for hour 9 (knows only about
hour 9) from clobbering request B for hour 10 (knows about both) when B
returned first and A straggled in — hour 10 would visually vanish.
Same race existed between the initial-mount loadFromServer and a quick
user toggle: whichever lost the race won the UI.
Fix: every request to /models/wake-schedule (GET and POST) bumps a single
monotonic seq. On response, sequencer.accept(seq) returns false if any
newer request has already won; we drop the snapshot.
Also drops the inFlightSeq mechanism entirely — it was redundant with
pendingHours for user clicks AND insufficient for the cross-hour and
initial-load races, so two mechanisms became one.
|
|
race guard
Tiny, dependency-free class for the common pattern where a component
fans out multiple HTTP calls that each return a full snapshot of
shared state, and applying an older snapshot would clobber a newer
one. begin() tags a new request, accept(seq) decides whether to
apply the response.
Pulled out as its own module (rather than inlined in ClaudeReset)
because the next consumer of this pattern shouldn't have to
re-derive it. The contract is small enough to test exhaustively
in isolation:
- accepts the first response unconditionally
- accepts responses in send order
- rejects an older response that arrives AFTER a newer one (the
core race that motivated this)
- rejects ALL stragglers once a newer one wins
- handles the initial-load vs first-click race
- equal seq is idempotent accept (defensive)
- begin() seqs are monotonic and unique
- state inspector reflects the watermark
8 tests, all green. No Svelte dependency — usable from any TS file.
|
|
A parent agent that spawns 8 subagents was producing 9 "Turn complete"
notifications per round — almost always noise. New `notifySubagents`
config flag (defaults to false) gates `turn-completed` and `turn-error`
from any tab with a `parentTabId`. The flag is intentionally NOT applied
to `permission-required` — a subagent's permission prompt still needs a
human tap to proceed, so suppressing it would silently hang the
subagent. `agent-spawned` is already top-level-only by construction.
Wiring:
- core/notifications/types.ts: NtfyConfig.notifySubagents: boolean
- core/notifications/config.ts: defaults to false; normalize() tolerates
missing / wrong-typed values and falls back to false
- core/notifications/dispatcher.ts: new optional TabParentLookup option
(getTabParentId). When notifySubagents=false AND the lookup returns a
non-empty parent id string, turn-completed/turn-error are dropped.
Lookup failures (no lookup configured, throws, returns undefined) fall
back to "treat as top-level" so legitimate top-level events are never
silently dropped when the DB is briefly unreadable.
- api/app.ts: wires getTabParentId via core's getTab(id)?.parentTabId
- frontend SettingsPanel.svelte: "Include subagent tabs" checkbox with
an explanatory hint that permission prompts still fire
Tests (+9):
- 3 in config.test.ts: default-false, explicit-true, wrong-typed fallback
- 6 in dispatcher.test.ts: suppression of turn-completed/turn-error from
subagents, no suppression when flag is true, permission-required not
gated, graceful fallback when lookup is missing/throws/returns undefined
Live ntfy.sh round-trip re-verified (status: 200).
|
|
same-tick fires
Marking an hour on the Claude Wake Schedule panel now schedules FOUR probes
within that hour instead of one. Rate-window edges are unforgiving — a
single probe at :15 can miss the actual reset moment by up to 14 minutes;
hitting :00 / :15 / :30 / :45 puts us within ~7 minutes of any reset that
happens during that hour.
When multiple slots come due in the same 30s scheduler tick (or recover
together at boot), they coalesce into a SINGLE upstream wake call — no
point hitting Anthropic 4× in the same window.
DB schema
- wake_schedule is now (hour, slot_minute, next_wake_at) PK (hour,
slot_minute). Destructive migration: detect old single-row-per-hour
schema by absence of the slot_minute column and DROP TABLE. No other
table is touched. Per user direction: no back-compat for old rows.
API
- POST /models/wake-schedule/toggle add: { hour, timestamps: { '0': ms,
'15': ms, '30': ms, '45': ms } } — all 4 slots required, all must be
future Unix ms. Delete shape unchanged ({ hour }).
- GET /models/wake-schedule shape:
schedule: { '9': { '0': ts, '15': ts, '30': ts, '45': ts }, ... }
probeSlotMinutes: [0, 15, 30, 45]
resetOffsetHours, lastWake, pendingRetry (unchanged from prior commit)
Frontend
- Computes 4 timestamps client-side (next occurrence of HH:MM in local TZ)
and sends them in one request.
- markedHours summary now says 'Probes :00 :15 :30 :45 → reset by ~Xh later'.
- Same in-flight tracking / current-hour ring / status row as before.
Tests
- wake-scheduler.test.ts unchanged (pure helpers still correct; added
PROBE_SLOT_MINUTES + isProbeSlotMinute exports).
- routes.test.ts rewritten for the new payload shape: 12 wake-schedule
tests covering snapshot shape, add/remove (full 4-slot round-trip),
validation (range, integer, past-slot, missing slot, non-object,
missing timestamps), independent multi-hour scheduling, and
re-toggle replacement. 417 tests total (was 414).
|
|
Click, support Basic auth, non-optimistic UI clear
Acted on 4 of 6 findings from the gemini-3-flash-preview second-opinion
review (the other 2 were verified-wrong or judged not worth the
complexity — see HANDOFF.md).
core/src/notifications/ntfy.ts:
- validateTopicUrl now enforces ntfy's actual topic-name constraints:
exactly one path segment, 1–64 chars, charset [A-Za-z0-9_-]. Prevents
users from saving topic URLs that look fine but silently 404 at
publish time (cf. binwiederhier/ntfy#1451 for the 64-char limit and
binwiederhier/ntfy's topic-name regex for the charset).
- Click header now passes through sanitizeHeader, closing the same
CRLF-injection vector that Title/Tags already had.
- Authorization header construction now factors through a small
buildAuthHeaderValue helper: a value that already starts with a scheme
token ("Bearer xyz", "Basic dXNlcjpwYXNz") is used verbatim, so users
of private ntfy servers that want Basic auth can paste the full header
value. Bare tokens still get the "Bearer " prefix automatically.
frontend/SettingsPanel.svelte:
- clearNtfyAuthToken() was optimistic: it flipped hasAuthToken=false
locally before awaiting the network call. If the request failed the
UI lied about server state, and worse — a subsequent Save() with
authToken:undefined would silently re-arm the original token. Now
awaits the response, surfaces failures via the existing ntfySaveError
banner, and only mutates local state on success. Adds a
ntfyClearingToken loading flag so the button disables + spins during
the request.
Tests: +6 in ntfy.test.ts (multi-segment rejection, charset rejection,
length boundary, 64-char acceptance, Basic auth pass-through, Click
sanitization). All 442 tests pass; biome clean; svelte-check clean;
manual ntfy.sh end-to-end re-verified.
|
|
Gemini review nit. The ✕ button on each sidebar slot (idx > 0) was
read by screen readers as "multiplication sign" or "cross". Adds
aria-label="Remove panel" so the action is announced clearly.
Also gitignore claude-report.md (Gemini review artifact, not source).
|
|
Gemini review surfaced that App.svelte (onMount theme apply) and
SettingsPanel.svelte (theme <select>) hand-rolled their own defaults
and could disagree:
- App.svelte only set data-theme if localStorage had a value, so on a
fresh install daisyUI fell back to the first theme in app.css (light).
- SettingsPanel.svelte hardcoded a UI default of "dark".
Result: a first-time user saw a light app but a Settings panel that
claimed "dark" was selected. Picking *any* value in the dropdown was
the only way to reconcile reality with the UI.
This commit:
- Adds packages/frontend/src/lib/theme.ts as the single source of truth:
THEMES list, Theme type, THEME_STORAGE_KEY, DEFAULT_THEME, plus
loadStoredTheme() and applyTheme() that handle SSR / private-mode /
bad-value cases.
- Rewires App.svelte's onMount to call applyTheme(loadStoredTheme()),
so the boot apply always writes a known good theme to the DOM (even
on fresh installs), matching what Settings will show.
- Rewires SettingsPanel.svelte's picker to use the shared module,
dropping its duplicate THEMES const, duplicate storage key, duplicate
apply/persist logic, and the conflicting "dark" fallback.
- Adds 11 unit tests in tests/theme.test.ts covering the
default-fallback, known/unknown stored values, SecurityError-on-read,
SSR (no localStorage), DOM-attribute write, persistence round-trip,
and the "DOM still updates if storage write throws" contract.
The daisyUI plugin block in app.css still lists themes — that's a
CSS-time concern and can't be imported from TS, so it's kept in sync
by convention (noted in the new module's doc comment).
|
|
tracking, status row
Bugs fixed
- fadedHours was $derived((): Set => {...}) — returned a *function*, not
a Set. blockClass() then called fadedHours() once per of the 24
buttons, defeating Svelte's memoization. Now uses $derived.by(() =>
Set), passed in to blockClass as a value.
- currentHour was $derived(new Date().getHours()) which is computed once
on mount and never updates. After midnight (or any hour boundary) the
'now' ring stayed on the wrong block. Now driven by a nowMs $state
bumped by a 30s setInterval, cleaned up on destroy.
- Rapid double-clicks could land out of order ('last response wins, not
last click'). Now tracks an in-flight Set + per-hour sequence counter;
stale responses are dropped and pending buttons are disabled.
- No feedback on wake success/failure. Snapshot now includes lastWake +
pendingRetry, surfaced as a colored status row.
Cleanups
- resetOffsetHours pulled from the server snapshot (was hardcoded +5).
- fadedHours window is now resetOffsetHours - 1 (was hardcoded 4).
- onclick handler short-circuits when the hour is already pending.
|
|
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.
|
|
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.
|