Skip to content

Comments

Lite client remote mode with PNS#1295

Merged
jb55 merged 64 commits intomasterfrom
wip/lite-client-remote-mode
Feb 18, 2026
Merged

Lite client remote mode with PNS#1295
jb55 merged 64 commits intomasterfrom
wip/lite-client-remote-mode

Conversation

@jb55
Copy link
Contributor

@jb55 jb55 commented Feb 17, 2026

Summary

  • Add NIP-PNS (Private Note Storage) crypto module for encrypted relay storage
  • Persist AI conversations as kind-1988 nostr events with NIP-10 threading, kind-1989 source-data companions, and kind-31988 session state
  • Enable remote session discovery and live polling via PNS-wrapped relay events
  • Add permission request/response events for phone-based remote session control
  • Lossless JSONL round-trip via source-data tags and session reconstructor
  • Code deduplication: extract 8 shared helpers, net -234 lines

Test plan

  • Verify session events round-trip (existing tests: 17 pass)
  • Test PNS encryption/decryption
  • Test remote session discovery via relay
  • Test permission request/response flow from phone
  • Verify code block syntax highlighting renders correctly

🤖 Generated with Claude Code

jb55 and others added 30 commits February 16, 2026 13:19
TableBuilder requires unique IDs when multiple tables exist in the
same Ui. Without id_salt, all tables shared the same auto-generated
ID, corrupting each other's column width state and breaking layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Initial implementation of JSONL to nostr event conversion:
- session_jsonl.rs: ProfileState-style Value wrapper parser
- path_normalize.rs: cwd-based path normalization
- session_events.rs: kind-1988 event builder with NIP-10 threading
- session_converter.rs: JSONL to ndb orchestrator
- session_loader.rs: ndb to Message loader (incomplete)
- Design doc with full spec

Shelved because the 1:1 JSONL-line-to-event model does not
cleanly handle mixed content blocks, tool_use/tool_result
pairing, or subagent conversation trees.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add monotonic seq tag for unambiguous event ordering, split tag for
multi-event assistant messages, tool-id tag pairing tool_call/result
events, and cwd tag. Source-data now stores raw JSONL verbatim (no path
normalization) and is only included on the first event of split groups.

Add session_reconstructor module that uses ndb.fold to reconstruct
original JSONL from stored events. Deduplicate get_tag_value into
session_events for reuse. Include async round-trip integration test
verifying JSONL → events → ndb → JSONL equality.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ume flow

Kind 1988 events are now lightweight (no source-data tag). A companion
kind 1989 event carries the raw JSONL per line, linked via e-tag and
sharing the same seq/d tags. Reconstructor queries 1989 instead of 1988.

On session resume, the JSONL file path is threaded through the UI and
converted to nostr events in update() where AppContext is available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… loading

- Wire set_sub_callback in ndb Config to trigger UI repaint on subscription matches
- Replace synchronous message loading with subscribe-before-ingest + poll pattern
  (process_event_with queues async indexing, so immediate loads found nothing)
- Fix file-history-snapshot lines lacking top-level timestamp/sessionId by
  adding context inheritance to ThreadingState (carries forward last-seen values)
- Add timestamp() fallback to snapshot.timestamp in session_jsonl.rs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…nversations

Refactor build_single_event to accept optional JsonlLine, allowing reuse
for both archive (JSONL) and live paths. Add build_live_event() for
generating 1988 events directly from role + content strings.

Wire into process_events() for assistant (on finalize), permission
requests, tool results, and errors. Wire into handle_user_send() for
user messages. No 1989 source-data events for live — archive only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Live events were starting a new NIP-10 root instead of threading onto
the existing archive conversation. Seed ThreadingState with root/last
note IDs from loaded archive events so resumed sessions thread correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… selection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements deterministic key derivation and NIP-44 v2 encryption for
kind-1080 PNS events. Used for encrypting AI conversation events
before publishing to relays for remote session control.

- derive_pns_keys(): HKDF-based key derivation from device secret key
- encrypt()/decrypt(): NIP-44 v2 with pre-derived conversation key
- 5 tests covering determinism, isolation, and round-trip

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…e polling

- Refactor BuiltEvent to store note_json (bare event JSON), add to_event_json()
  for ndb ingestion. All build sites updated.
- ingest_live_event() returns Option<BuiltEvent> for relay publishing
- process_events() returns (sessions, events) tuple, update() publishes to pool
- pending_relay_events queue for events from non-pool contexts (handle_user_send)
- build_permission_request_event(): kind-1988 with perm-id, tool-name, t:ai-permission
- build_permission_response_event(): kind-1988 response with e-tag linking to request
- Subscribe for remote permission responses when claude_session_id is learned
- poll_remote_permission_responses(): routes relay events through existing oneshot
  channels, first-response-wins with local UI
- AgenticSessionData: add perm_request_note_ids, perm_response_sub fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Relay publishing of AI conversation events is commented out to prevent
accidentally broadcasting plaintext conversations. Will be re-enabled
once NIP-PNS wrapping is wired in.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Done sessions were being silently added to the queue but never
auto-focused, making it easy to miss completed work. Now auto-steal
focuses Done sessions after NeedsInput, returning home only when
neither remain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Prevent jarring session switches by checking if the active session
has non-empty input. Auto-steal resumes after the message is sent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use egui_extras::syntax_highlighting for colored code blocks in both
complete and streaming renders. Supports Rust, C/C++, Python, and
TOML with no new dependencies. Results are frame-cached.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Publish a parameterized replaceable event (NIP-33) on every agent
status transition and title change. On startup, query ndb for these
events to restore sessions and skip the directory picker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Spawn process_pns thread on first update to unwrap kind-1080 events.
Bump nostrdb dep to 9aeecd3 which adds ndb_process_pns support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
All kind-1988/1989/31988 events are now encrypted with NIP-PNS
(kind-1080 envelope) before being sent to relays. Adds wrap_pns()
helper that encrypts inner event JSON and signs with derived PNS
keypair.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Subscribe for kind-1080 (PNS) events authored by our derived PNS pubkey
so that session state events from other devices arrive via relay pool.
Add local ndb subscription for kind-31988 session state events and poll
each frame to detect newly-unwrapped sessions, creating them in the UI
with conversation history and threading state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remote sessions discovered via relay have no local Claude process.
Permission responses publish as nostr events instead of oneshot
channels. Session status derived from kind-31988 state events.

- Add SessionSource enum to ChatSession (Local vs Remote)
- Remote-aware derive_status(), has_pending_permissions(),
  first_pending_permission(), handle_permission_response()
- PermissionResponseResult propagated through UI via new variants
  on KeyActionResult, SendActionResult, UiActionResult
- Load permission_request/response events in session_loader
- Guard local-only ops (state publish, git status, remote perm poll)
- Remote user messages publish to relay, skip local backend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Subscribe to kind-1988 events for remote sessions and poll for new
messages in the update loop. Dedup via seen_note_ids HashSet seeded
from initial session restore. Also update remote_status when new
kind-31988 events arrive for existing sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add PNS_RELAY_URL constant and use send_to for both subscription and
event publishing. Ensures the relay is in the pool before use. Avoids
blasting PNS-wrapped events to all relays in the pool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of ingesting raw 1988/31988 events directly, PNS-wrap them
first and ingest the 1080 wrapper. ndb process_pns handles unwrapping
internally. This ensures 1080 events are in the local db for relay
sync. Also fixes session state events (31988) not being published to
the relay at all.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add query_replaceable() helper that folds over replaceable events,
keeping only the latest revision per d-tag. Fixes duplicate sessions
appearing in the side menu when multiple kind-31988 revisions exist
in ndb.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
pns_ingest was passing bare {...} JSON to ndb.process_event which
expects ["EVENT","subid", {...}] format, silently failing. Use
process_client_event with ["EVENT", {...}] wrapping instead. This
was why no 1988/31988 events were appearing in ndb during sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The old code called Hkdf::new() then hk.expand(), which runs both
HKDF-Extract and HKDF-Expand. The nostrdb C code only does raw
HMAC-SHA256 (equivalent to HKDF-Extract). The extra Expand step
produced different keys, so ndb couldn't match the PNS pubkey and
1080 events were never auto-unwrapped.

Add a test vector from nostrdb's test_pns_unwrap (device key 0x02)
to ensure the derived PNS pubkey matches the C implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Local sessions were only sending permission responses through the
oneshot channel to the Claude process, without creating a nostr event.
On session reload, the permission_request events existed in ndb but
no matching permission_response events, so all requests appeared
unresolved.

Now both local and remote sessions publish permission response events,
so resolved state persists across reloads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a session was deleted, the kind 31988 replaceable event persisted
in ndb and on relays, causing deleted sessions to reappear on restart.

Now we publish a replacement 31988 event with status "deleted" which
overwrites the old state. Session loading and live polling both filter
out deleted sessions.

Also fixes: PNS ingest uses process_event with relay format so ndb
triggers PNS unwrapping, and Claude stream errors from unknown message
types (e.g. rate_limit_event) are now non-fatal warnings instead of
killing the session. Resumed sessions always send just the latest
message since Claude Code already has context via --resume.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Session metadata (title, cwd, status) is now stored as tags rather
than serialized JSON in the content field. This avoids unnecessary
JSON parsing when reading session state, especially in the
query_replaceable_filtered predicate that checks for deleted sessions.

Also adds query_replaceable_filtered which takes a predicate closure
to reject notes during the fold pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- switch_and_focus_session(): consolidates switch_to/select/focus_on/focus_requested pattern (8 call sites)
- cycle_agent(): generic direction-based agent cycling, used by next/prev
- secret_key_bytes(): replaces 5 copies of keypair→secret_key→as_secret_bytes→try_into
- init_note_builder(): replaces duplicated NoteBuilder::new().kind().content().options() chains (6 call sites)
- finalize_built_event(): replaces duplicated sign/build/id/json pattern (6 call sites)
- now_secs(): replaces 4 copies of SystemTime::now timestamp boilerplate

Net: 128 insertions, 316 deletions across 3 files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
jb55 and others added 9 commits February 17, 2026 14:34
Wrap diff view in a ScrollArea with max 400px height so large diffs
no longer extend off screen. Move allow/deny buttons to bottom left
for easier access on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remote sessions have no local git repo or interruptible process,
so hide the git status bar and the "press esc to interrupt" hint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Changed permission_buttons from right-to-left to left-to-right
layout so Allow/Deny buttons appear at the bottom left instead
of top right, making them easier to reach on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add note IDs to seen_note_ids in ingest_live_event so events echoed
back from the relay are skipped. Subscribe local sessions to
conversation events and handle incoming user messages from remote
clients, dispatching them to the backend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Keep full diff height visible, only scroll horizontally for long lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Non-diff permission UIs had buttons inline with the tool name,
causing them to appear top-right. Move them to a separate line.
Also fix plan approval buttons to use left-to-right layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
StripBuilder with clip=true sets TextWrapMode::Truncate on the
cell style, causing all text to truncate instead of wrapping
when the window is narrowed. Override to Wrap at dave UI entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sessions now include a hostname tag in kind-31988 events, parsed on
restore, and displayed as hostname:cwd in the session list sidebar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jb55 jb55 force-pushed the wip/lite-client-remote-mode branch from ff90477 to 9dbf828 Compare February 17, 2026 23:50
jb55 and others added 19 commits February 17, 2026 15:56
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When multiple user messages arrived from a remote client (phone) while
the backend was already streaming a response, each new message would
call send_user_message_for and overwrite the active stream. This caused
responses to lag behind by one message.

Now we guard dispatch: skip if the session is already streaming (the
message is already in chat), and re-dispatch when the stream ends if
there are unanswered user messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handle_question_response() only sent through the local oneshot
channel, never publishing to relays for remote sessions. Mirror the
permission response flow: return PermissionResponseResult, insert into
responded_perm_ids for remote, and propagate PublishPermissionResponse
from the UI action handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract a shared PermissionPublish struct to replace the duplicated
{perm_id, allowed, message} fields across PermissionResponseResult,
UiActionResult, KeyActionResult, SendActionResult, and
PendingPermResponse. Handlers now return Option<PermissionPublish>,
making it impossible to forget relay publishing for new prompt types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tool result messages from nostr notes were showing "tool" as the tool
name instead of the actual name (e.g. "Bash", "Read") because the
tool-name tag was never written when building events. The session_loader
was already reading this tag but it always fell back to "tool".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three publish functions (session states, deletions, permission responses)
all repeated the same build→log→pns_ingest→queue pattern. Unified into
a single queue_built_event() helper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Unifies three scattered permission fields (pending_permissions,
perm_request_note_ids, responded_perm_ids) into a single
PermissionTracker struct with a merge_loaded() helper for the
common session-restore pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When multiple revisions of a kind-31988 session state event arrive
out of order (e.g. after relay reconnect from sleep), the last one
processed would win regardless of timestamp. Track remote_status_ts
so we only apply updates from newer events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When multiple revisions of the same kind-31988 session state arrive in
one poll batch (e.g. after relay reconnect), an older non-deleted
revision could be processed after the newer deleted one, creating a
phantom session. Deduplicate the batch by d-tag before processing so
only the latest revision per session is used.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ndb.fold iterates notes in storage order, not chronological. If a
newer deleted revision was visited before an older non-deleted one,
the predicate rejection was a no-op (nothing to remove yet), and the
older revision would then be inserted unchallenged — resurrecting
deleted sessions as zombies.

Fix by always tracking the highest created_at per d-tag regardless of
predicate result, storing Option<NoteKey> so rejected revisions still
block older ones from winning.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handle_permission_response() and handle_question_response() each
independently updated req.response, permissions.responded, and the
oneshot channel — three redundant state locations that had to stay in
sync. When 950b430 fixed permission responses to always publish nostr
events, the Q&A path was missed, requiring a catch-up fix (4c05d07).

Add PermissionTracker::resolve() as the single method that updates all
resolution state. Both handlers now funnel through it, so future
changes to resolution logic only need to touch one place.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
After restarting Dave, restored local sessions had no
live_conversation_sub because the subscription was only created for
remote sessions. This meant remote user messages (e.g. from phone)
were ignored until a local message triggered the backend, which
created the subscription via SessionInfo. Subscribe to conversation
events for all restored sessions regardless of local/remote status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the static "(⇧ for message)" hint with a clickable "+ msg"
link that enters tentative mode without needing a shift key. Also
make the "Will Allow"/"Will Deny" labels clickable to toggle between
accept and deny. This enables permission feedback from mobile clients
where no shift key is available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The PLAN and AUTO toggle badges were not clickable (using hover sense)
and were placed in the input box area. This moves them to the git
status bar (right of the refresh button) and makes them clickable to
toggle plan mode and auto-steal focus mode respectively.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The status bar with badges was only rendered when git_status was
available, which excluded remote sessions (e.g. Android). Now the
status bar renders with just the toggle badges when there's no git
status but we're in agentic mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When in tentative state (composing a message to attach to a permission
response), replace Allow/Deny buttons with a Send button so tapping
the button actually submits the response with the typed message. This
fixes mobile where clicking Allow/Deny would discard the message.

Extract tentative_send_ui() and add_msg_link() helpers to share the
tentative-state UI between permission_buttons and exit_plan_mode_ui.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jb55 jb55 merged commit 8ad2c98 into master Feb 18, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant