Investigating 'apps blank while a bg task runs': measured serving latency
through a real scheduled bg-task run — 1-3ms on both loopbacks throughout,
so the server is not the bottleneck. Found and fixed the likely renderer-era
culprit plus made the failure observable:
- agent sync (apps:list, polled every 4s) rewrote each bundled agent's
task.yaml unconditionally — racing the bg runner's own patches mid-run and
spamming watcher events; now writes only when the definition changed
- AppFrame load watchdog: if the iframe hasn't loaded in 6s, show a visible
'taking too long' state with Retry instead of a silent blank pane
- main event-loop lag monitor: stalls >300ms are logged so future blank
reports can be tied to the offending work
- checkCapability choke point (D7): tools/llm/copilot access requires the
capability in the app's manifest; 403 capability_not_declared otherwise
- POST /_rowboat/tools/search|execute: Composio pass-through with the same
request shape as the builtin, typed error mapping
- POST /_rowboat/fetch: SSRF-guarded proxy (loopback/RFC1918/link-local/ULA/
*.localhost rejection re-checked per redirect hop, 5MB cap + truncated flag,
30s timeout, Host/Cookie stripped)
- POST /_rowboat/llm/generate: default-model plumbing, override validated
against the allowed set, token/concurrency caps, usage attributed
app_llm_generate/<folder>
- POST /_rowboat/copilot/run: headless run (bg-task tool profile, no shell),
app-attributed audit turn with origin context, 10-min timeout, 1-per-app cap
- registered M2 routes are POST-only (GETs are D17-exempt and must not reach them)
- bundled agents (§8): strict AppAgentDefinitionSchema, deterministic
app--<folder>--<name> slugs materialized disabled with sourceApp, manifest
removals deactivate, app delete removes owned tasks; sourceApp in summaries
- new use cases app_llm_generate / app_copilot_run in runs + analytics enums
macOS's resolver maps *.apps.localhost to ::1 only, so Electron's iframe
connects to [::1]:3210 while the server listened on 127.0.0.1 only —
connection refused, blank app, while external browsers succeeded via their
own IPv4 fallback. Listen on both loopback addresses (::1 best-effort for
IPv6-disabled machines).
- watch ~/.rowboat/apps: data.json changes push into open apps (shim
re-fetches, onData fires); installs/updates re-list the gallery live and
reload an open app once the write settles
- atomic installs (temp->rename) so an app opened mid-install never reads a
partial file
- serve app assets by direct disk read instead of net.fetch(file://) —
Chromium's network service could stall and blank every open app at once
New bridge commands: "model" lists the available models (same catalog as
the desktop picker), "model N" / "model <name>" sets a per-sender
override passed as agent.overrides.model on every turn, and
"model default" resets to the app default. In-memory scope: resets on
app restart, never sticks a session to a bad model.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a transport-agnostic messaging bridge in @x/core that drives the
session/turn runtime from your phone: WhatsApp links as a companion
device via Baileys QR pairing (self-chat is the command channel),
Telegram long-polls the user's own bot token. Commands: list, resume N,
new, status, stop; anything else runs a turn in the current session and
replies with the final assistant text. Ask-human questions are relayed
and answerable from the phone.
- packages/core/src/channels/: bridge (commands, settle watcher,
ask-human relay), WhatsApp + Telegram transports, config repo, service
(lifecycle, status fan-out, QR rendering, current-transport reply
routing, dynamic baileys import)
- Settings → Mobile tab: enable toggles, QR pairing, bot token,
sender allowlists
- Strict sender authorization: WhatsApp self-chat + allowlisted numbers
(LID-aware), Telegram allowlisted chat IDs only
- Telegram offset persisted across restarts; generation-guarded
WhatsApp socket lifecycle; vitest coverage for the bridge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Remove the "Mark as read" toggle for Everything else end-to-end (UI, IPC,
config, sync auto-read, markSectionRead) — it could bulk-mark backlog and
archived mail read on the server during cache-recovery syncs
- Sending an edited draft now reuses the draft's stored In-Reply-To/References
instead of self-referencing its own Message-ID (external clients thread
correctly); standalone drafts send as fresh messages
- Keep draft autosaves out of the sync pipeline: skip DRAFT history messages
(no more md/knowledge-event leaks, phantom "New email" notifications, or
per-autosave LLM reclassification) and mirror the draft body onto the cached
snapshot surgically instead of waking the sync loop
- Only recreate a Gmail draft when update fails with 404/410 — transient
errors no longer pile up duplicates
- Emptying a draft and closing now deletes it from Gmail (matches Gmail)
- Forward keydown out of message iframes so j/k/e/r etc. keep working after
clicking into an email body; restore expanded quotes across theme reloads
- Composer footer wraps on narrow panes: formatting toolbar + Discard move as
one unit instead of overflowing into each other
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Welcome step's "Use Next or your arrow keys" hint stays in the bubble
but is dropped from the voice clip (its tail kept synthesizing with
gibberish). Steps gain an optional voiceText override, honored by both
the clip generator and the live-TTS fallback; the generator also takes
step ids as args to re-roll individual clips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bundle the 11 tour narration clips as renderer assets so the tour never
calls ElevenLabs (works offline and signed-out). useVoiceTTS gains
speakUrl() which plays a ready URL through the same queue/analyser path,
keeping lip-sync and cancellation intact. Clips are regenerated with
scripts/generate-tour-audio.mjs, which parses TOUR_STEPS from
product-tour.tsx and synthesizes via @x/core. Default TTS voice is now
s3TPKV1kjDlVtZbl4Ksh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One-time startup migration that scans ~/.rowboat/runs/*.jsonl and converts
each legacy run into the new event-sourced runtime, so existing chats and
agent history are visible and continuable after the runtime rewrite.
Mapping (a run is one whole conversation; turn boundaries are user messages):
- copilot_chat run -> 1 session (sessionId = original runId) + one turn per
user message (turn ids <runId>-tNNN).
- every other run -> a single standalone turn whose id IS the original runId,
so live-note (lastRunId) and background-task (runs.log)
history views resolve it via sessions:getTurn with no
renderer change.
- code_session runs are skipped (Code mode still uses the old runtime).
- convert.ts: pure convertRun(); synthesizes a reduceTurn/reduceSession-legal
event log (exact request refs, permission replay, denials -> runtime isError
results, reasoning parts preserved) and validates via the reducers before
returning.
- migrate.ts: defensive IO runner. Successful runs are moved to runs-archive/
(that move is the idempotency guard); failures are left in place (still
served by the runs:fetch fallback) and retried next launch. Per-run
try/catch never blocks boot. Writes config/runs-migration.json.
- main.ts: runs before sessions.initialize() and logs an [runs-migration]
summary (N turns across M sessions, skipped/failed counts).
- Tests: 13 cases over 4 real (redacted) run fixtures — conversion, session
chaining, deny->error, reasoning preservation, transcript fidelity,
archive-on-success, skip, quarantine, idempotency, and read-back through the
real FSTurnRepo/FSSessionRepo.
Dry-run on real data: 19 runs -> 3 sessions + 21 turns, 0 failures.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
List: j/k/arrow cursor with focus ring, Enter/o open, e archive, # trash,
u read toggle, c/n compose, / search, g+i / g+d view switch, ? help overlay,
layered Escape (suggestions > link bar > composer > thread > search).
Thread: r / a / f open reply, reply-all (with fallback), forward.
Composer: Cmd/Ctrl+Enter send (commits half-typed recipients), Cmd/Ctrl+
Shift+C/B reveal+focus Cc/Bcc, Esc closes with draft autosave.
Perf: content-visibility row virtualization, pointer-events suppression
while scrolling, memoized ThreadRow so cursor moves and page appends only
re-render affected rows.
Animations: 160ms row slide-out on archive/trash/draft-delete with snap-back
on failure, fast thread-detail and inline-composer open transitions, 140ms
ease-out animated scrolling for cursor-follow and thread-open (respects
prefers-reduced-motion).
Also fixes stale search-result rows on archive/read actions and Escape
closing the whole compose modal while a suggestion menu or link bar was open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
apps/x gains npm test — builds @x/shared first (the core and renderer
suites import its dist), then runs the shared, core, and renderer
vitest suites in dependency order; per-suite test:shared/test:core/
test:renderer scripts for targeted runs.
.github/workflows/x-tests.yml runs that harness on every pull request
touching apps/x (plus pushes to main and manual dispatch): pnpm 10 /
Node 22, frozen lockfile, pnpm store cached against apps/x's lockfile.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Decision: defer code-mode unification; no deletions. The legacy runs
runtime (runs/, AgentRuntime/streamAgent, runs:* IPC, App.tsx legacy
tab state) remains solely for code-mode sessions. AGENTS.md gets the
authoritative carve-out section: what stays and why, the no-new-callers
rule (headless work uses agents/headless.ts), the temporary fallbacks
that die with unification, and the scoped future project (rowboat-mode
code prompts as composition turns; direct-mode ACP streams as a
delegated turn kind). Turn-runtime design doc status updated to
implemented-and-live.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Analytics: the new runtime emitted usage into durable events but never
reported it to PostHog (only the permission classifier self-reported).
New IUsageReporter seam on the turn loop — invoked once per completed
model call, after the durable append, failure-isolated. The real bridge
emits the same llm_usage event as the old loop; useCase/subUseCase come
from the AsyncLocalStorage context the stage-6 headless runners already
establish via withUseCase, defaulting to copilot_chat for UI-driven
session turns (matching the old createRun default). Capturing at the
loop covers ALL turns — the session bus never sees headless ones.
Voice: the new renderer path rendered <voice> tags literally and never
fired TTS. The live overlay now extracts completed <voice> blocks
(robust to tags split across deltas) into chatState.voiceSegments;
App speaks new segments via the existing useVoiceTTS when voice output
is on, skipping segments streamed before a session became active. Tags
are stripped from both the streaming message and persisted assistant
messages, mirroring the legacy display-time strip.
Tests: usage report assertion in the runtime suite; four voice tests
(split-delta extraction, per-call scan reset, state stripping+segments,
persisted-message stripping).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Background-task and live-note history views called the legacy runs:fetch
for ids that are now turn ids (ENOENT after stage 6). New shared loader
src/lib/agent-transcript.ts fetches sessions:getTurn first and falls
back to runs:fetch so pre-migration histories stay readable (fallback
dies with the runs runtime in stage 7); turn transcripts render through
the same buildTurnConversation used by the chat views. Unit-tested
(turnToTranscript mapping + failure surfacing).
The chat-log download used runs:downloadLog with what is now a session
id. Added sessions:downloadLog (concatenates the session's turn logs
into one JSONL via the save dialog); the sidebar tries it first and
falls back to runs:downloadLog for legacy background tabs.
Remaining renderer runs:* callers are code-mode only (use-code-chat,
runs:events feed) — the deliberate stage-7 carve-out.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New src/agents/headless.ts wraps the turn runtime in the old headless
calling convention: startHeadlessAgent returns the turn id immediately
(callers record it in pointer files / bus events before completion) and
a done promise settling with { outcome, state, summary };
runHeadlessAgent awaits it. throwOnError reproduces the old
waitForRunCompletion({ throwOnError }) semantics via HeadlessRunError;
summary reproduces extractAgentResponse (last assistant text);
toolInputPaths replaces the run-bus tool-invocation subscriptions by
reading invoked calls from durable turn state. Model overrides pair the
caller's model id with the app-default provider. Unit-tested against an
injected fake runtime (7 tests).
Migrated all nine callers:
- background-tasks/runner: handle start wrapped in withUseCase so tools
(notify-user) read the use case via AsyncLocalStorage
- knowledge/live-note/runner: same shape, gains withUseCase
- pre_built/runner, knowledge/agent_notes: run-and-wait
- knowledge/tag_notes, label_emails, build_graph: edited/created paths
now come from turn state (toolInputPaths) instead of bus streaming
- knowledge/inline_tasks (both sites): summary text feeds the existing
marker parsing unchanged
- agent-schedule/runner: fire-and-forget start with
AbortSignal.timeout(TIMEOUT_MS); dropped the now-unused
runsRepo/agentRuntime/idGenerator plumbing
Code-mode sessions remain on the runs infrastructure (deliberate
carve-out until stage 7 scoping); agents/utils.ts stays for
launch-code-task's extractAgentResponse. The notify-user useCase gate
already prefers ALS with a best-effort fetchRun fallback, so it works
unchanged for turn ids.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Third application of the reference mechanism: session turns whose
system prompt + tools are byte-identical to the context predecessor's
materialized snapshot persist { agentId, model, inheritedFrom } instead
of ~70KB per turn (measured: turn 2 of a session is now ~1.1KB total).
Inheritance is decided at createTurn by equality against the
materialized predecessor; the model stays concrete, and on
materialization the inherited record's own agentId/model win — a rule
the new test matrix caught as a real bug (the chain base's model was
overriding a mid-session model switch). Reducer invariants: inherited
snapshots must reference the context predecessor; tool identity arrives
via invocation events.
Test matrix per review: prompt-diff -> full snapshot, tools-diff ->
full snapshot, model-switch -> inherits with concrete model, multi-hop
chains materialize, standalone turns never inherit, unreadable
predecessor falls back to full, cyclic inheritance is corruption,
sessions denormalize the model from inherited snapshots.
The inspector now handles sessions too (auto-detected): overview with
per-turn status/size/input preview, --turns to cascade full turn
inspection. Documented in a new repo-root AGENTS.md (storage layout,
reference model, inspector usage, invariants) with a CLAUDE.md pointer.
Breaking for dev turn files: wipe ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two debuggability follow-ups from reviewing real turn files:
- Request refs are now compact strings — 'context' | 'input' |
'assistant:<index>' | 'toolResult:<toolCallId>' — so a raw JSONL line
reads naturally: {"messages": ["assistant:0", "toolResult:toolu_…"]}.
Same exact-match reducer invariants, via parseRequestRef.
- npm run inspect-turn -- <turnId|path> [callIndex] [--full]: prints,
per model call, the EXACT provider payload (resolved system prompt,
tool list, wire-form messages with user-message context woven in, and
the response/failure) — rebuilt by the same composer the loop sends
through. This is the missing viewer for the derived-not-duplicated
request design: the file stores facts once; the inspector shows what
the model actually received.
Breaking for turn files written since the previous commit (dev only):
wipe ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
model_call_requested carried three duplications that dominated turn-file
size (measured on a real 647KB two-step turn): the system prompt (28KB)
and the 38-tool snapshot (40KB) repeated per call despite being byte-
identical to turn_created.agent.resolved, and messages re-inlining the
whole current-turn transcript (230KB re-stating a 207KB tool result).
ModelRequest is now a list of references into the turn's own events —
call 0: [{context}?, {input}]; call N: [{assistant: N-1}, ...that
batch's toolResults in source order]; re-issue after an interruption:
[]. Every referenced byte exists exactly once in the file; the measured
turn drops to ~285KB and each further model call costs ~200 bytes. The
reducer's ordering invariant got stricter: reference lists are matched
exactly against the transcript.
Debuggability is now byte-for-byte at the wire level: ResolvedModel
gains encodeMessages (the structural->wire conversion — user-message
context weaving, attachment rendering, tool-result enveloping, i.e.
convertFromMessages), and composeModelRequest rebuilds the exact
provider payload (resolved system prompt + wrapped tools + materialized
prefix + resolved refs, encoded). The loop transmits exactly the
composer's output, so the durable file plus the composer reproduce what
the model received — pinned by a property test asserting composed ==
sent for every call, and a 2KB size guard on request events. A bridge
test demonstrates the woven wire form (no raw userMessageContext).
Breaking for existing dev turn files (same schemaVersion, pre-release):
wipe ~/.rowboat/storage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two follow-ups from live testing:
1. Permission/ask-human buttons were disabled while a turn waited for
input. PermissionRequest disables via isProcessing, whose old-runtime
meaning was 'agent busy right now' (false while awaiting approval);
the sessions meaning is 'turn not settled' (true while suspended).
Cards in both chat views now take the isThinking flag — actively
working disables, waiting on the user enables — while the composer
and stop button stay on isProcessing.
2. The middle-pane (full-screen) chat rendered blank: App.tsx has its
own conversation block whose activeChatTabState was still assembled
from legacy standalone states. activeChatTabState is now the single
hook-backed source of truth for the active tab; the sidebar props,
the middle pane, the merged background-tab map, and the submit guard
all derive from it (plus hook-driven activeIsProcessing/
activeIsThinking), so both views land on identical data.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>