Skip to content

feat: add Nostr chat via CLI#2

Merged
joemarct merged 25 commits into
masterfrom
feat/nostr-chat
Jun 23, 2026
Merged

feat: add Nostr chat via CLI#2
joemarct merged 25 commits into
masterfrom
feat/nostr-chat

Conversation

@joemarct

Copy link
Copy Markdown
Member

Add Nostr chat CLI commands using NIP-17 gift-wrapped messaging, matching the paytaca-app protocol.

New Commands

Chat

  • chat list — list conversations with latest message and unread count
  • chat open <room-id> — view messages (fetched fresh from relay)
  • chat send <room-id> <text> — send a message
  • chat listen — subscribe to new messages (long-running)
  • chat add-contact <npub> [name] — add a contact
  • chat contacts — list contacts with resolved names and BCH addresses
  • chat identity — show your Nostr npub and pubkey

Profile

  • chat set-display-name <name> — publish display name (kind 30078)
  • chat remove-display-name — remove published display name
  • chat set-bch-address <address> — publish BCH address (kind 30078)
  • chat remove-bch-address — remove published BCH address

Architecture

  • NIP-06 key derivation from BIP39 mnemonic
  • NIP-17 gift-wrapped messages (kind 1059) via nostr-tools
  • Relay-first: rooms/messages fetched from relays each session
  • Sent messages persisted locally (mirrors app behavior — self-wrap ['self'] tag is relay-rejected but sent messages survive via disk persistence)
  • NIP-42 AUTH support for restricted relays
  • Contacts, display name cache, BCH address cache persisted to ~/.paytaca/chat-state.json
  • Skills at skills/ remain as checked-in files (not symlinks to gitignored .agents/)

@opencode-agent

Copy link
Copy Markdown
Contributor

PR Review: feat: add Nostr chat via CLI

Overall Assessment: The PR is well-structured and brings a significant feature with clean separation of concerns (commands, protocol, relay, store). The build passes cleanly and existing tests remain green. However, there are 2 critical bugs that must be fixed before merge, plus several moderate issues and code quality improvements.


🔴 Critical Issues (must fix)

1. Bug: chat listen --contact <npub> filter never matches

File: src/commands/chat.ts (lines 572–573)

When the user passes a raw npub1... to --contact, the code stores the npub string in filterPubKey but message.sender is always a hex pubkey. The comparison message.sender !== filterPubKey will therefore never match, so no messages are displayed when filtering by npub.

Fix: Decode the npub to hex before assigning:

} else if (opts.contact.startsWith('npub1')) {
  const decoded = nip19Decode(opts.contact)
  if (decoded.type === 'npub') {
    filterPubKey = decoded.data as string
  }
}

2. Bug: Unread count is always equal to total incoming messages

File: src/commands/chat.ts (lines 101–104), src/nostr/store.ts

readMessageIds is loaded from disk and used to compute the unread badge, but it is never written to anywhere in the codebase. The chat open command should mark the viewed messages as read.

Fix: In the chat open handler, after rendering messages, add:

store.readMessageIds[room.id] = store.readMessageIds[room.id] || {}
for (const msg of allMsgs) {
  store.readMessageIds[room.id][msg.id] = true
}
store.saveState()

🟡 Moderate Issues

3. Silent error swallowing

There are many empty catch {} blocks that hide failures:

  • src/nostr/store.ts lines 88–112 (loadPersistedData), 114–128 (saveState), 137–140, 142–153 (initialize), 165–170 (subscribe)
  • src/nostr/relay.ts lines 174–175, 200–201, 298–300, 345–346, 361–362, 377–378

Recommendation: At minimum log errors to stderr (e.g. console.error('[nostr] loadPersistedData failed:', err)). This makes debugging relay/auth/fs issues possible.

4. hexToBytes duplicated in 3 files

Files: src/commands/chat.ts, src/nostr/chat.ts, src/nostr/relay.ts

nostr-tools/utils already exports hexToBytes. Remove all three copies and import:

import { hexToBytes } from 'nostr-tools/utils'

5. Global singleton state in relay.ts makes subscriptions fragile

File: src/nostr/relay.ts

All subscriptions, pools, and timers are stored in module-level variables. If one relay closes, onclose sets _isSubscribed = false globally, which causes the keepalive interval to tear down and recreate subscriptions for ALL relays, including healthy ones. This creates unnecessary churn and duplicate connections.

Recommendation: Track subscription state per-relay, or at least only call scheduleResubscribe when all _subs are closed, not on every individual onclose.

6. Unused backoff constant

File: src/nostr/relay.ts (line 93)

RESUBSCRIBE_BASE_MS is defined but never used. scheduleResubscribe always waits RESUBSCRIBE_MAX_MS (60s). Implement exponential backoff or remove the unused constant.


🟢 Minor / Suggestions

7. add-contact npub validation is weak

File: src/commands/chat.ts (lines 255–258)

Only checks startsWith('npub1'). A typo like npub1abc passes. Use nip19Decode inside a try/catch for robust validation.

8. Consider extracting process.exit calls

File: src/commands/chat.ts (throughout)

Heavy use of process.exit(1) inside command handlers makes unit testing impossible. Consider throwing typed errors and letting the top-level program.parse() catch block handle exit codes.

9. Two publish functions with different signatures

File: src/nostr/relay.ts

publish and publishEvent do nearly the same thing. Consider unifying them to a single API.


✅ Positive Notes

  • Clean architecture: Commands → Store → Relay/Chat/Keys is easy to follow.
  • NIP-17 gift wraps: Correctly implemented with nip59.wrapManyEvents and unwrapEvent.
  • NIP-06 derivation: Correct path m/44'/1237'/0'/0/0.
  • NIP-42 AUTH: Properly wired via SimplePool automaticallyAuth.
  • Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
  • Build & tests: tsc passes with zero errors, all 20 existing tests pass.

Verdict

Approve with requested changes — the two critical bugs (npub filter and unread messages) are straightforward fixes and should be addressed before merge. The moderate issues (error swallowing, duplication, singleton state) are important but can be addressed in follow-up PRs if preferred.

New%20session%20-%202026-06-23T09%3A01%3A42.991Z
opencode session  |  github run

1. chat listen --contact: decode npub to hex before filtering
2. chat open: mark messages as read (persist readMessageIds)
3. All catch blocks: add console.error with descriptive prefix
4. hexToBytes: deduplicate via import from nostr-tools/utils
5. relay sub onclose: per-relay tracking with _activeSubRelays set
6. Remove unused RESUBSCRIBE_BASE_MS constant
@opencode-agent

Copy link
Copy Markdown
Contributor

PR Review: feat: add Nostr chat via CLI

Overall Assessment: A solid, well-structured feature addition. The architecture (commands → store → relay/chat/keys) is clean, and the Nostr protocol implementation (NIP-06, NIP-17, NIP-42, NIP-78) is largely correct. Since the prior review, the two critical bugs have been fixed in the current code (npub decoding in chat listen, and readMessageIds persistence in chat open). Build passes, all 20 existing tests are green.

However, there are 2 new functional bugs and several data inconsistencies that should be addressed before merge.


🔴 Critical Issues (must fix)

1. Bug: chat open accepts prefixes in help text but only does exact matching

File: src/commands/chat.ts (line 113), src/nostr/store.ts (line 339)

The argument description says Room ID (full or prefix), but store.getRoom() only checks r.id === roomId. Passing a prefix always returns "Room not found".

Fix: Update getRoom() in store.ts to support prefix matching:

getRoom(roomId: string): Room | undefined {
  return this.rooms.find(r => r.id === roomId || r.id.startsWith(roomId))
}

2. Bug: Message edits are silent and don't bump the room

File: src/nostr/store.ts (lines 290–297)

In receiveMessage(), when editOf is found, the function returns early after mutating the target message. It never updates room.updatedAt and never calls onNewMessageCallback. This means:

  • Edits don't bump the room to the top of chat list
  • chat listen subscribers never see edit notifications
  • The room appears stale

Fix: After applying the edit, continue with the normal flow or at least update the room and trigger the callback:

if (editOf) {
  const msgs = this.messages[roomId] || []
  const target = msgs.find(m => m.id === editOf)
  if (target) {
    target.content = rumor.content
    room.updatedAt = Math.max(room.updatedAt, rumor.created_at)
    // Optionally notify
    return
  }
}

🟡 Moderate Issues

3. Data inconsistency in JSON outputs

  • chat list JSON lacks unread count (regular CLI output shows it).
  • chat open JSON lacks editOf (regular CLI output shows (edited)).

These should be included so JSON consumers get the same data as interactive users.

4. Dead code

  • src/commands/chat.ts line 21–23: shortPubkey() is defined but never used.
  • src/nostr/store.ts lines 414–436: findOrCreatePrivateRoom() is never called.
  • src/nostr/chat.ts lines 133–159: createReactionGiftWraps() is exported but unused.

5. --tail 0 behaves as --tail 20

File: src/commands/chat.ts (line 133)

const tail = parseInt(opts.tail, 10) || 20

parseInt("0", 10) returns 0, which is falsy, so tail becomes 20. If a user wants to see just the room header with 0 messages, they can't.

Fix: Use Number.isNaN():

const tail = Number.isNaN(parseInt(opts.tail, 10)) ? 20 : parseInt(opts.tail, 10)

6. resolveDisplayName refuses to cache short names

File: src/nostr/store.ts (lines 384–386)

if (contact?.name && contact.name.length > 12) {
  this.displayNameCache[pubKeyHex] = contact.name
  return contact.name
}

This means if a user names a contact "Jo", it is re-fetched from relays on every chat list / chat open / chat contacts call. The > 12 heuristic is meant to avoid caching auto-truncated hex placeholders, but it also blocks legitimate short names.

Fix: Cache the contact name regardless of length, or use a different discriminator (e.g. check if it contains ...).


🟢 Minor / Suggestions

7. chat set-bch-address lacks address validation

File: src/commands/chat.ts (line 464)

The command accepts any string and publishes it. Consider validating cashaddr format before publishing to avoid publishing garbage data.

8. chat open marks all messages as read, not just the ones viewed

File: src/commands/chat.ts (lines 192–196)

When --tail is used, only the last N messages are displayed, but the code marks all messages in the room as read. This is minor but slightly inconsistent.

9. publish and publishEvent still coexist

File: src/nostr/relay.ts (lines 243–293)

As noted in the prior review, these two functions do almost the same thing with different return shapes. Unifying them would reduce surface area.

10. Global singleton state in relay.ts

File: src/nostr/relay.ts

All subscriptions and timers are still module-level. This is acceptable for a CLI tool where each command is a short-lived process, but it makes the relay.ts module hard to test and can cause churn if multiple relays are used (as noted in the prior review).


✅ Verification of Prior Review Fixes

Issue Status Evidence
chat listen --contact npub never matched Fixed chat.ts lines 572–578 now properly nip19Decode the npub to hex
Unread count never persisted Fixed chat.ts lines 192–196 now save readMessageIds on chat open
Silent error swallowing Partially fixed Most catch blocks now log to stderr with console.error
hexToBytes duplicated Fixed All three files import from nostr-tools/utils
Unused RESUBSCRIBE_BASE_MS Fixed Constant no longer present in the file

✅ Positive Notes

  • NIP-17 gift wraps: Correctly implemented. I verified nostr-tools's wrapManyEvents always prepends a self-wrap at index 0, and the tagSelfGiftWraps logic correctly tags the self-wrap plus any duplicate sender entries in the recipients list.
  • NIP-06 derivation: Correct path m/44'/1237'/0'/0/0.
  • NIP-42 AUTH: Properly wired via SimplePool automaticallyAuth.
  • NIP-78 profile events: Correct parameterized replaceable format with d tags.
  • Build & tests: tsc passes cleanly, all 20 existing tests pass.

Verdict

Approve with requested changes — the two critical bugs from the prior review are already resolved in the current code. The two new functional bugs (chat open prefix matching and silent edits) are straightforward fixes and should be addressed before merge. The moderate issues (JSON consistency, dead code, --tail parsing) are also quick wins. The remaining structural issues (singleton state, dual publish functions) can be addressed in follow-up PRs if preferred.

New%20session%20-%202026-06-23T09%3A10%3A12.922Z
opencode session  |  github run

joemarct and others added 2 commits June 23, 2026 19:01
- Prefix matching in getRoom() (r.id.startsWith)
- Message edits now bump room.updatedAt and notify subscribers
- JSON outputs include unreadCount and editOf
- Remove unused shortPubkey, findOrCreatePrivateRoom, createReactionGiftWraps
- --tail 0 works correctly (Number.isNaN check)
- Cache short display names (use ... heuristic instead of length check)
- Validate BCH cashaddr format before publishing
- --tail only marks viewed messages as read
- Add cashaddr format validation to set-bch-address
Co-authored-by: joemarct <joemarct@users.noreply.github.com>
@opencode-agent

Copy link
Copy Markdown
Contributor

PR Review: feat: add Nostr chat via CLI

Overall Assessment: The PR is well-structured with clean separation between commands, store, relay, and protocol layers. The Nostr implementation (NIP-06, NIP-17, NIP-42, NIP-78) is correct and the build passes cleanly. All 20 existing tests remain green. I applied a few targeted fixes for issues I found during review; the remaining items are architectural debt that can be addressed in follow-ups.


✅ Fixed During Review

Issue Fix
Critical: chat open with a prefix returned empty messages getMessages now uses the resolved room.id instead of the raw user-provided prefix
Moderate: chat send allowed prefix lookup in getRoom but then failed in sendMessage with exact-match lookup sendMessage now receives room.id, and the argument help is updated to (full or prefix)
Moderate: chat listen JSON output omitted replyTo and editOf Added both fields to JSON lines
Minor: chat listen non-JSON output did not show (edited) for edits Added the (edited) annotation

🔴 Critical Issues (from prior reviews) — Verified Fixed

  1. chat listen --contact <npub> now correctly decodes npub1... to hex before filtering.
  2. Unread count persistence now saves readMessageIds after chat open.
  3. chat open prefix matching works at the room level (store.getRoom supports prefixes).
  4. Message edits now bump room.updatedAt and trigger the onNewMessage callback.
  5. --tail 0 parsing is now Number.isNaN-safe.
  6. resolveDisplayName short-name caching now uses !includes('...') instead of a length gate.
  7. hexToBytes duplication removed; all files import from nostr-tools/utils.
  8. Silent error swallowing largely resolved; catch blocks now log to stderr.

🟡 Moderate Issues (remaining)

  1. publish and publishEvent still coexist
    src/nostr/relay.ts exports two publish functions with slightly different return shapes. Unifying them into a single API would reduce surface area and test complexity.

  2. Global singleton state in relay.ts
    All subscriptions, pools, and timers are module-level. This is acceptable for a short-lived CLI process, but it makes the module hard to unit test and can cause unnecessary churn if multiple relays are in use.

  3. Edit race condition
    receiveMessage silently drops edits when the target message hasn't been fetched yet. If the original message arrives after the edit, the edit is lost. Buffering unapplied edits would be more robust.


🟢 Minor / Suggestions

  1. add-contact npub validation is robust because nip19Decode throws on invalid data, but the user gets a generic error. Proactive try/catch around nip19Decode in the command handler would produce a clearer message.
  2. Heavy process.exit usage in chat.ts command handlers makes unit testing impossible. Consider throwing typed errors and letting the top-level program.parse() catch block handle exit codes.
  3. chat open marks only the viewed tail as read — this is actually the correct behavior, but worth noting that the unread badge reflects the exact subset displayed.

✅ Positive Notes

  • NIP-17 gift wraps: Correctly implemented with wrapManyEvents and tagSelfGiftWraps.
  • NIP-06 derivation: Correct path m/44'/1237'/0'/0/0.
  • NIP-42 AUTH: Properly wired via SimplePool automaticallyAuth.
  • NIP-78 profile events: Correct parameterized replaceable format with d tags.
  • Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
  • Build & tests: tsc passes with zero errors, all 20 existing tests pass.

Verdict

Approve with minor follow-ups — the critical functional issues from this and prior reviews are now resolved. The two publish-function duplication and the singleton state in relay.ts are worth cleaning up in a follow-up PR, but they don't block merge.

New%20session%20-%202026-06-23T11%3A01%3A58.692Z
opencode session  |  github run

@opencode-agent

Copy link
Copy Markdown
Contributor

User opencode-agent[bot] does not have write permissions

github run

joemarct and others added 2 commits June 23, 2026 19:23
- RelayService class with proper instance state (testable, no globals)
- Single publish() API that accepts NostrEvent | NostrEvent[]
- Buffered pending edits to fix race condition (edit arriving before target)
Co-authored-by: joemarct <joemarct@users.noreply.github.com>
@opencode-agent

Copy link
Copy Markdown
Contributor

PR Review: feat: add Nostr chat via CLI

Overall Assessment: The PR is in solid shape and ready to merge. The architecture is clean (commands → store → relay/chat/keys), the Nostr protocol implementation is correct, and the build/tests are green. All critical and moderate bugs from previous review rounds have been addressed. I applied one targeted fix for a hang risk during review.


✅ Fixed During Review

fetchKind10050 missing maxWait timeout
relay.ts called pool.querySync without a maxWait option, which meant an unresponsive relay could cause chat send to hang indefinitely. I added { maxWait: 10000 } to match the other querySync calls in the same file.


✅ Verification of Prior Review Fixes

Issue Status Evidence
chat listen --contact <npub> never matched Fixed chat.ts lines 580–586 now properly decodes the npub to hex before filtering
Unread count never persisted Fixed chat.ts lines 195–199 now save readMessageIds after chat open
chat open prefix matching Fixed store.ts getRoom() supports prefix matching via r.id.startsWith(roomId)
Message edits silent / not bumping room Fixed store.ts lines 291–307 now updates room.updatedAt and triggers onNewMessage for edits; unapplied edits are buffered in pendingEdits
--tail 0 parsing Fixed chat.ts line 135 uses Number.isNaN
resolveDisplayName short-name caching Fixed store.ts line 406 uses !includes('...') instead of a length gate
hexToBytes duplication Fixed All files import from nostr-tools/utils
Silent error swallowing Fixed catch blocks now log to stderr with console.error
Global singleton state in relay.ts Fixed relay.ts is now a proper RelayService class with instance-level state
publish + publishEvent duplication Fixed Single publish() API that accepts NostrEvent | NostrEvent[]
Dead code (shortPubkey, findOrCreatePrivateRoom, createReactionGiftWraps) Fixed Removed in prior commits
JSON outputs missing unread / editOf / replyTo Fixed Added to chat list and chat open JSON outputs

🟡 Minor Issues (remaining, non-blocking)

  1. Unhandled rejections in most command handlers
    chat send, chat open, chat list, chat listen, etc. do not wrap their async bodies in try/catch. If store.initialize or network calls throw unexpectedly, the process exits with an ugly stack trace and store.cleanup() is skipped. This is acceptable for a v1 CLI but makes the module harder to test.

  2. SIGTERM not handled in chat listen
    The long-running listen command installs a SIGINT handler but not SIGTERM. In Docker/systemd environments, the process may not shut down cleanly.

  3. Unused lastSubscribeTime in RelayService
    The property is assigned in subscribeGiftWraps but never read.


✅ Positive Notes

  • NIP-17 gift wraps: Correctly implemented with wrapManyEvents and tagSelfGiftWraps.
  • NIP-06 derivation: Correct path m/44'/1237'/0'/0/0.
  • NIP-42 AUTH: Properly wired via SimplePool automaticallyAuth.
  • NIP-78 profile events: Correct parameterized replaceable format with d tags.
  • Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
  • Build & tests: tsc passes cleanly, all 20 existing tests pass.

Verdict

Approve — the critical functional issues from prior reviews are resolved, the structural refactor is clean, and the remaining items are minor code-quality improvements that can be addressed in follow-up PRs.

New%20session%20-%202026-06-23T11%3A23%3A51.896Z
opencode session  |  github run

@paytaca paytaca deleted a comment from opencode-agent Bot Jun 23, 2026
Only triggers on /oc or /opencode comments to avoid bot permission errors.
@paytaca paytaca deleted a comment from opencode-agent Bot Jun 23, 2026
@paytaca paytaca deleted a comment from github-actions Bot Jun 23, 2026
@paytaca paytaca deleted a comment from github-actions Bot Jun 23, 2026
@paytaca paytaca deleted a comment from github-actions Bot Jun 23, 2026
@paytaca paytaca deleted a comment from github-actions Bot Jun 23, 2026
joemarct and others added 2 commits June 23, 2026 20:52
@opencode-agent

Copy link
Copy Markdown
Contributor

PR Review: feat: add Nostr chat via CLI

Overall Assessment: The PR is in solid shape. The architecture (commands → store → relay/chat/keys) is clean, the Nostr protocol implementation (NIP-06, NIP-17, NIP-42, NIP-78) is correct, and the build/tests are green. All critical and moderate bugs from previous review rounds have been addressed. I applied several targeted fixes during this review.


✅ Fixed During Review

Issue Fix
--tail 0 shows all messages Array.prototype.slice(-0) is equivalent to slice(0) and returns the entire array. Added a guard so tail === 0 returns an empty array.
Sent messages loaded out of order loadPersistedData appended persisted sent messages at the end of the array without sorting. Fixed by inserting them in chronological order (matching receiveMessage).
SIGTERM not handled Added a SIGTERM handler to chat listen so the process shuts down cleanly in Docker/systemd environments.
Unused lastSubscribeTime Removed the dead property and its assignments from RelayService.
State file permissions chat-state.json now writes with mode 0o600 so it isn't world-readable.

✅ Verification of Prior Review Fixes

Issue Status Evidence
chat listen --contact npub never matched Fixed chat.ts decodes npub1... to hex before filtering
Unread count never persisted Fixed chat.ts saves readMessageIds after chat open
chat open prefix matching Fixed store.ts getRoom() supports prefix matching
Message edits silent / not bumping room Fixed store.ts updates room.updatedAt and triggers onNewMessage for edits; buffers unapplied edits
--tail 0 parsing Fixed chat.ts uses Number.isNaN
resolveDisplayName short-name caching Fixed store.ts uses !includes('...')
hexToBytes duplication Fixed All files import from nostr-tools/utils
Silent error swallowing Fixed catch blocks now log to stderr
Global singleton state Fixed relay.ts is now a proper RelayService class
publish + publishEvent duplication Fixed Single publish() API

🟡 Minor Issues (remaining, non-blocking)

  1. unwrapGiftWrap swallows seal verification errors
    The try/catch around the manual seal check only re-throws the specific "Seal pubkey does not match rumor pubkey" error. Other nip44.decrypt failures are silently ignored.

  2. pendingEdits grows unbounded in long-running listen
    If an edit arrives but the original message never does, the buffered edit stays in pendingEdits forever. For a short-lived CLI process this is negligible.

  3. No unit tests for the new chat feature
    The 20 existing tests still pass, but there are no new tests for ChatStore, RelayService, or the command handlers. Consider adding unit tests for the store/room logic (relay interaction can be mocked).


✅ Positive Notes

  • NIP-17 gift wraps: Correctly implemented with wrapManyEvents and tagSelfGiftWraps.
  • NIP-06 derivation: Correct path m/44'/1237'/0'/0/0.
  • NIP-42 AUTH: Properly wired via SimplePool automaticallyAuth.
  • NIP-78 profile events: Correct parameterized replaceable format with d tags.
  • Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
  • Build & tests: tsc passes cleanly, all 20 existing tests pass.

Verdict

Approve — the critical functional issues from prior reviews are resolved, the new fixes I applied during review are minor and clean, and the remaining items are code-quality improvements that can be addressed in follow-up PRs.

New%20session%20-%202026-06-23T12%3A52%3A39.116Z
opencode session  |  github run

@opencode-agent

Copy link
Copy Markdown
Contributor

User opencode-agent[bot] does not have write permissions

github run

@opencode-agent

Copy link
Copy Markdown
Contributor

PR Review: feat: add Nostr chat via CLI

Overall Assessment: The PR is well-structured and ready to merge. The architecture (commands → store → relay/chat/keys) is clean, the Nostr protocol implementation (NIP-06, NIP-17, NIP-42, NIP-78) is correct, and the build/tests are green. I verified the current codebase compiles with zero errors and all 20 existing tests pass.


✅ Verification of Prior Review Fixes

Issue Status Evidence
chat listen --contact <npub> never matched Fixed chat.ts lines 579–585 decodes npub1... to hex before filtering
Unread count never persisted Fixed chat.ts lines 195–199 save readMessageIds after chat open
chat open prefix matching Fixed store.ts getRoom() supports prefix matching via startsWith
Message edits silent / not bumping room Fixed store.ts lines 294–310 updates room.updatedAt and triggers onNewMessage for edits; buffers unapplied edits
--tail 0 parsing Fixed chat.ts line 135 uses Number.isNaN with a guard for tail === 0
resolveDisplayName short-name caching Fixed store.ts line 409 uses !includes('...')
hexToBytes duplication Fixed All files import from nostr-tools/utils
Silent error swallowing Fixed catch blocks now log to stderr with console.error
Global singleton state Fixed relay.ts is now a proper RelayService class
publish + publishEvent duplication Fixed Single publish() API accepting NostrEvent | NostrEvent[]
Dead code Fixed shortPubkey, findOrCreatePrivateRoom, createReactionGiftWraps removed
JSON outputs missing unread / editOf / replyTo Fixed Added to chat list and chat open JSON outputs
fetchKind10050 missing timeout Fixed maxWait: 10000 added
SIGTERM not handled Fixed Added to chat listen
Sent messages loaded out of order Fixed loadPersistedData inserts in chronological order
State file permissions Fixed writeFileSync uses mode: 0o600

🟡 Minor Issues (remaining, non-blocking)

  1. chat open --json doesn't mark messages as read
    The non-JSON path saves readMessageIds after displaying messages (lines 195–199), but the --json path exits before doing so. This means JSON consumers won't get the unread badge cleared. Consider moving the read-marking logic before the JSON exit, or explicitly documenting that --json is read-only.

  2. unwrapGiftWrap still swallows non-specific seal verification errors
    src/nostr/chat.ts lines 108–110 only re-throws the exact string "Seal pubkey does not match rumor pubkey". Other nip44.decrypt failures (e.g., corrupted seal, wrong key) are silently ignored and the rumor is returned anyway. Since nip59.unwrapEvent already handles the primary unwrap, this is a secondary sanity check, but silently dropping verification failures is a minor security gap.

  3. publish can report duplicate errors for multi-wrap publishes
    RelayService.publish iterates over events and accumulates errors without deduplication. When publishing giftWraps (one per recipient), the same relay can appear in errors multiple times. The accepted list uses seenRelays to deduplicate, but errors does not, making the success denominator (accepted.length + errors.length) potentially misleading.

  4. chat listen prints "Connected" before actual connection
    The Connected. message is logged immediately after store.subscribe(), which is synchronous. The WebSocket connection is still establishing in the background. If the relay is unreachable, the user sees "Connected" followed by silence. A small UX papercut.

  5. editOf semantic inversion on edited messages
    In receiveMessage, when an edit is applied to an existing message, the original message's editOf field is set to the edit's rumor ID (target.editOf = rumor.id). Semantically, editOf should mean "this message is an edit of X", not "this message was edited by X". Since the edit itself is not stored as a separate message, this pragmatic inversion is accepted, but the data model is slightly confusing.

  6. Group room display names are arbitrary
    getOtherMember returns the alphabetically first non-self member for any room. For group chats, this is a poor room name (e.g., showing "Alice" for a group with Alice, Bob, and Charlie). The room.name is actually set to the first sender's display name in receiveMessage, which is also not ideal for groups. This is acceptable for a v1, but group chat UX could be improved later.


🟢 Positive Notes

  • NIP-17 gift wraps: Correctly implemented with wrapManyEvents and tagSelfGiftWraps.
  • NIP-06 derivation: Correct path m/44'/1237'/0'/0/0.
  • NIP-42 AUTH: Properly wired via SimplePool automaticallyAuth.
  • NIP-78 profile events: Correct parameterized replaceable format with d tags.
  • Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
  • BCH address validation: Basic cashaddr format check is present in set-bch-address.
  • Build & tests: tsc passes cleanly, all 20 existing tests pass.

Verdict

Approve — all critical and moderate functional issues from previous review rounds are resolved. The remaining items are minor code-quality and UX improvements that can be addressed in follow-up PRs.

New%20session%20-%202026-06-23T13%3A11%3A07.681Z
opencode session  |  github run

- Max 1000 messages per room (trim oldest)
- Max 1000 readMessageIds per room
- Max 100 pending edit entries, 10 per target
Co-authored-by: joemarct <joemarct@users.noreply.github.com>
@opencode-agent

Copy link
Copy Markdown
Contributor

PR Review: feat: add Nostr chat via CLI

Overall Assessment: The PR is well-structured and ready to merge. The architecture (commands → store → relay/chat/keys) is clean, the Nostr protocol implementation (NIP-06, NIP-17, NIP-42, NIP-78) is correct, and the build/tests are green. All critical and moderate bugs from previous review rounds have been addressed. I applied several targeted fixes during this review.


✅ Fixed During Review

Issue Fix
chat listen --contact <name> filter mismatch When a contact name was not found, the code logged "Watching all conversations" but filterPubKey remained set to the user's own pubkey, so it still filtered by self. Now filterPubKey is correctly set to null.
chat open --json doesn't mark messages as read The non-JSON path saved readMessageIds after displaying messages, but the --json path exited before doing so. Moved the read-marking logic before the JSON exit so JSON consumers also clear the unread badge.
relay.ts onclose crash risk Added !reasons guard before reasons.includes('closed by caller') to prevent a potential runtime crash if the callback receives an undefined value.
unwrapGiftWrap swallowed seal verification errors Removed the overly broad try/catch that only re-threw one specific error string. All seal verification failures (corrupted seal, wrong key, pubkey mismatch) now propagate correctly.

✅ Verification of Prior Review Fixes

Issue Status Evidence
chat listen --contact <npub> never matched Fixed chat.ts decodes npub1... to hex before filtering
Unread count never persisted Fixed chat.ts saves readMessageIds after chat open
chat open prefix matching Fixed store.ts getRoom() supports prefix matching
Message edits silent / not bumping room Fixed store.ts updates room.updatedAt and triggers onNewMessage for edits; buffers unapplied edits
--tail 0 parsing Fixed chat.ts uses Number.isNaN with a guard for tail === 0
resolveDisplayName short-name caching Fixed store.ts uses !includes('...')
hexToBytes duplication Fixed All files import from nostr-tools/utils
Silent error swallowing Fixed catch blocks now log to stderr with console.error
Global singleton state Fixed relay.ts is now a proper RelayService class
publish + publishEvent duplication Fixed Single publish() API accepting NostrEvent | NostrEvent[]
Dead code Fixed shortPubkey, findOrCreatePrivateRoom, createReactionGiftWraps removed
JSON outputs missing unread / editOf / replyTo Fixed Added to chat list and chat open JSON outputs
fetchKind10050 missing timeout Fixed maxWait: 10000 added
SIGTERM not handled Fixed Added to chat listen
Sent messages loaded out of order Fixed loadPersistedData inserts in chronological order
State file permissions Fixed writeFileSync uses mode: 0o600

🟡 Minor Issues (remaining, non-blocking)

  1. publish can report duplicate errors for multi-wrap publishes
    RelayService.publish iterates over events and accumulates errors without deduplication. When publishing giftWraps (one per recipient), the same relay can appear in errors multiple times. The accepted list uses seenRelays to deduplicate, but errors does not, making the success denominator (accepted.length + errors.length) potentially misleading.

  2. chat listen prints "Connected" before actual connection
    The Connected. message is logged immediately after store.subscribe(), which is synchronous. The WebSocket connection is still establishing in the background. If the relay is unreachable, the user sees "Connected" followed by silence. A small UX papercut.

  3. editOf semantic inversion on edited messages
    In receiveMessage, when an edit is applied to an existing message, the original message's editOf field is set to the edit's rumor ID (target.editOf = rumor.id). Semantically, editOf should mean "this message is an edit of X", not "this message was edited by X". Since the edit itself is not stored as a separate message, this pragmatic inversion is accepted, but the data model is slightly confusing.

  4. Group room display names are arbitrary
    getOtherMember returns the alphabetically first non-self member for any room. For group chats, this is a poor room name (e.g., showing "Alice" for a group with Alice, Bob, and Charlie). The room.name is actually set to the first sender's display name in receiveMessage, which is also not ideal for groups. This is acceptable for a v1, but group chat UX could be improved later.

  5. No unit tests for the new chat feature
    The 20 existing tests still pass, but there are no new tests for ChatStore, RelayService, or the command handlers. Consider adding unit tests for the store/room logic (relay interaction can be mocked).


🟢 Positive Notes

  • NIP-17 gift wraps: Correctly implemented with wrapManyEvents and tagSelfGiftWraps.
  • NIP-06 derivation: Correct path m/44'/1237'/0'/0/0.
  • NIP-42 AUTH: Properly wired via SimplePool automaticallyAuth.
  • NIP-78 profile events: Correct parameterized replaceable format with d tags.
  • Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
  • BCH address validation: Basic cashaddr format check is present in set-bch-address.
  • Build & tests: tsc passes cleanly, all 20 existing tests pass.

Verdict

Approve — all critical and moderate functional issues from previous reviews are resolved, the new fixes I applied during review are clean and minimal, and the remaining items are minor code-quality and UX improvements that can be addressed in follow-up PRs.

New%20session%20-%202026-06-23T13%3A17%3A59.106Z
opencode session  |  github run

@opencode-agent

Copy link
Copy Markdown
Contributor

User opencode-agent[bot] does not have write permissions

github run

@github-actions

Copy link
Copy Markdown

Author identity unknown

*** Please tell me who you are.

Run

git config --global user.email "you@example.com"
git config --global user.name "Your Name"

to set your account's default identity.
Omit --global to set the identity only in this repository.

fatal: empty ident name (for runner@runnervm7b5n9.xzi531oxwoauxd4ge3usvb0tpb.bx.internal.cloudapp.net) not allowed

opencode session  |  github run

@github-actions

Copy link
Copy Markdown

fatal: could not read Username for 'https://github.com': No such device or address

opencode session  |  github run

@github-actions

Copy link
Copy Markdown

PR Review: feat: add Nostr chat via CLI

Overall Assessment: The PR is in good shape after multiple review rounds. The architecture (commands → store → relay/chat/keys) is clean, the Nostr protocol implementation (NIP-06, NIP-17, NIP-42, NIP-78) is correct, and the build/tests are green. I verified the current codebase compiles with zero errors and all 20 existing tests pass. Most critical issues from prior reviews have been resolved. There is 1 remaining critical bug and a few moderate/minor items.


🔴 Critical Issue (must fix)

1. store.ts onEvent callbacks swallow unwrapGiftWrap failures silently

File: src/nostr/store.ts (lines 158–160, 177–183)

Both initialize (historical fetch) and subscribe (live subscription) pass onEvent callbacks that have empty catch {} blocks around unwrapGiftWrap. If a gift wrap is corrupted, tampered, or decrypted with the wrong key, the failure is silently discarded with no audit trail. This is a robustness and minor security gap.

Fix: Log the error to stderr before discarding:

} catch (err) {
  console.error('[store] unwrapGiftWrap failed:', err)
}

🟡 Moderate Issues (should fix)

2. RelayService.publish errors array lacks deduplication

File: src/nostr/relay.ts (lines 248–249)

When publishing multi-wrap batches (one per recipient), the same relay can appear in errors multiple times if it rejects multiple events. The accepted list uses seenRelays to deduplicate, but errors does not. Additionally, a relay could theoretically appear in both accepted and errors if it accepts one event but rejects another in the same batch, making the success denominator (accepted.length + errors.length) misleading.

Fix: Use a seenErrorRelays Set to deduplicate errors, similar to seenRelays for accepted.

3. chat listen prints "Connected" before the WebSocket is established

File: src/commands/chat.ts (line 640)

The Connected. message is logged immediately after the synchronous store.subscribe() call. The actual WebSocket connection is still establishing asynchronously. If the relay is unreachable, the user sees "Connected" followed by silence. This is a UX papercut.

Fix: Move the log into the onevent callback or add a connection-status callback, or simply remove it and rely on the initial listening message.

4. editOf semantic inversion is confusing

File: src/nostr/store.ts (line 301)

When an edit is applied, the original message's editOf field is set to the edit rumor's ID (target.editOf = rumor.id). Semantically, editOf should mean "this message is an edit of X", not "this message was edited by X". Since the edit itself is not stored as a separate message, this pragmatic inversion is accepted, but the field name is misleading for any consumer of the JSON API.

Fix: Consider renaming to editedBy or lastEditId, or document the inversion explicitly.

5. No way to start a new conversation from the CLI

File: src/commands/chat.ts

chat send only works for existing rooms (rooms created by receiveMessage). There is no chat create or chat start command. This means a user cannot initiate a new 1:1 conversation with a freshly added contact from the CLI alone — they must wait for the contact to message first (or use the mobile app).

Fix: Add a chat start <npub> command that computes the room ID and creates the room, or document this limitation.

6. No unit tests for the new chat feature

The 20 existing tests pass, but the new ChatStore, RelayService, chat.ts commands, and Nostr protocol helpers have zero unit test coverage. The store/room logic is testable with mocked relay interaction.


🟢 Minor / Suggestions

7. Group room display names are arbitrary

File: src/nostr/store.ts (line 280, 452–456)

getOtherMember returns the first non-self member (alphabetically first hex pubkey). For group chats, room.name is set to the first sender's display name. Both are poor heuristics for group chat UX. Acceptable for v1, but worth noting.

8. relay.ts seenEventIds trimming logic is duplicated

File: src/nostr/relay.ts (lines 140–143, 182–185)

The 5000-entry cap cleanup is copy-pasted in onevent and the polling interval. Extract to a private method.

9. add-contact npub validation is weak before nip19Decode

File: src/commands/chat.ts (lines 258–261)

The startsWith('npub1') check passes obviously invalid strings like npub1abc. The nip19Decode call in addContact will throw a clear error, but the add-contact command handler could give a faster, more helpful message by wrapping nip19Decode in a try/catch directly.

10. .github/workflows/opencode.yml changes

The added Configure git step and persist-credentials: true correctly address the prior Author identity unknown and write-permission failures. The workflow looks correct.


✅ Verification of Prior Review Fixes

Issue Status Evidence
chat listen --contact <npub> never matched Fixed chat.ts lines 581–586 decodes npub1... to hex
Unread count never persisted Fixed chat.ts lines 145–149 save readMessageIds after chat open
chat open prefix matching Fixed store.ts getRoom() supports prefix matching
Message edits silent / not bumping room Fixed store.ts lines 294–310 updates room.updatedAt and triggers onNewMessage for edits
--tail 0 parsing Fixed chat.ts line 135 uses Number.isNaN with a guard for tail === 0
resolveDisplayName short-name caching Fixed store.ts line 428 uses !includes('...')
hexToBytes duplication Fixed All files import from nostr-tools/utils
Silent error swallowing (outer catches) Fixed catch blocks now log to stderr with console.error
Global singleton state Fixed relay.ts is now a proper RelayService class with instance-level state
publish + publishEvent duplication Fixed Single publish() API accepting NostrEvent | NostrEvent[]
Dead code Fixed shortPubkey, findOrCreatePrivateRoom, createReactionGiftWraps removed
JSON outputs missing unread / editOf / replyTo Fixed Added to chat list and chat open JSON outputs
fetchKind10050 missing timeout Fixed maxWait: 10000 added
SIGTERM not handled Fixed Added to chat listen
Sent messages loaded out of order Fixed loadPersistedData inserts in chronological order
State file permissions Fixed writeFileSync uses mode: 0o600

✅ Positive Notes

  • NIP-17 gift wraps: Correctly implemented with wrapManyEvents and tagSelfGiftWraps.
  • NIP-06 derivation: Correct path m/44'/1237'/0'/0/0.
  • NIP-42 AUTH: Properly wired via SimplePool automaticallyAuth.
  • NIP-78 profile events: Correct parameterized replaceable format with d tags.
  • BCH address validation: Basic cashaddr format check is present in set-bch-address.
  • Build & tests: tsc passes cleanly, all 20 existing tests pass.

Verdict

Approve with requested changes — the codebase is solid and the feature is well-architected. Only one critical issue remains: the empty catch blocks in store.ts that silently swallow unwrapGiftWrap errors. The RelayService.publish deduplication issue and the chat listen UX papercut are also worth fixing. The remaining items are minor code-quality improvements that can be addressed in follow-up PRs.

New%20session%20-%202026-06-23T13%3A40%3A38.368Z
opencode session  |  github run

1. Log unwrapGiftWrap failures, skip known self-wraps
2. Deduplicate publish errors per relay
3. Remove misleading 'Connected' message
4. Accept editOf semantic inversion
5. Accept no chat start command
6. Accept no unit tests
7. Accept group room name heuristic
8. Extract trimSeenEventIds private method
9. Remove redundant startsWith npub check (addContact validates)
@github-actions

Copy link
Copy Markdown

PR Review: feat: add Nostr chat via CLI

Overall Assessment: The PR is well-structured and ready to merge. The architecture (commands → store → relay/chat/keys) is clean, the Nostr protocol implementation (NIP-06, NIP-17, NIP-42, NIP-78) is correct, and the build/tests are green. All critical and moderate bugs from previous review rounds have been addressed. I verified the current codebase compiles with zero errors and all 20 existing tests pass.

However, there is 1 remaining critical bug that needs fixing.


🔴 Critical Issue (must fix)

1. chat listen crashes when --contact <name> is not found

File: src/commands/chat.ts (lines 582–594)

When opts.contact is a name that doesn't exist in the contacts list, the code correctly sets filterPubKey = null and logs "Watching all conversations." But immediately after, the non-JSON path builds a target string:

const target = filterPubKey === store.keys?.pubKeyHex
  ? 'your conversations'
  : `conversations with ${store.getContactName(filterPubKey!)}`

Because filterPubKey is null, the first condition is false, so it falls into the else branch. The ! non-null assertion suppresses the compile-time check, but at runtime filterPubKey is actually null. getContactName(null) then calls pubKeyHex.slice(0, 12) on null, producing:

TypeError: Cannot read properties of null (reading 'slice')

Fix: Handle the null case explicitly before the comparison:

const target = !filterPubKey
  ? 'all conversations'
  : filterPubKey === store.keys?.pubKeyHex
    ? 'your conversations'
    : `conversations with ${store.getContactName(filterPubKey)}`

🟡 Moderate Issues (should fix, but non-blocking)

2. RelayService.publish hides partial-relay rejections

File: src/nostr/relay.ts (lines 232–263)

The resolvedRelays Set is shared across all events in a batch. If a relay accepts the first gift-wrap but rejects the second, the first result "wins" and the second failure is silently skipped. For chat send, the user sees "accepted by 1/1 relays" even though one of the gift-wraps was rejected. In practice, relays usually accept or reject all events from the same publisher, but this is a minor reporting gap.

3. No unit tests for the new chat feature

The 20 existing tests pass, but ChatStore, RelayService, chat.ts commands, and the Nostr protocol helpers have zero unit test coverage. The store/room logic is testable with mocked relay interaction.

4. chat listen prints "Listening" before the WebSocket is established

The Listening for new messages... message is logged immediately after the synchronous store.subscribe() call. The actual WebSocket connection is still establishing asynchronously. If the relay is unreachable, the user sees the listening message followed by silence. A small UX papercut.

5. editOf semantic inversion is confusing

When an edit is applied, the original message's editOf field is set to the edit rumor's ID (target.editOf = rumor.id). Semantically, editOf should mean "this message is an edit of X", not "this message was edited by X". The pragmatic inversion is acceptable for a v1, but worth documenting for JSON consumers.


🟢 Minor / Suggestions

6. add-contact npub validation is weak before nip19Decode

The startsWith('npub1') check passes obviously invalid strings like npub1abc. The nip19Decode call in addContact will throw a clear error, but the add-contact command handler could give a faster, more helpful message by wrapping nip19Decode in a try/catch directly.

7. Heavy process.exit usage in chat.ts makes unit testing impossible

Consider throwing typed errors and letting the top-level program.parse() catch block handle exit codes. This would make the command handlers testable.

8. Group room display names are arbitrary

getOtherMember returns the first non-self member (alphabetically first hex pubkey). For group chats, room.name is set to the first sender's display name. Both are poor heuristics for group chat UX. Acceptable for v1.


✅ Verification of Prior Review Fixes

Issue Status Evidence
chat listen --contact <npub> never matched Fixed chat.ts lines 576–582 decode npub1... to hex
Unread count never persisted Fixed chat.ts lines 145–149 save readMessageIds after chat open
chat open prefix matching Fixed store.ts getRoom() supports prefix matching via startsWith
Message edits silent / not bumping room Fixed store.ts lines 294–310 updates room.updatedAt and triggers onNewMessage for edits
--tail 0 parsing Fixed chat.ts line 135 uses Number.isNaN with a guard for tail === 0
resolveDisplayName short-name caching Fixed store.ts line 432 uses !includes('...')
hexToBytes duplication Fixed All files import from nostr-tools/utils
Silent error swallowing Fixed catch blocks now log to stderr with console.error
Global singleton state Fixed relay.ts is now a proper RelayService class with instance-level state
publish + publishEvent duplication Fixed Single publish() API accepting NostrEvent | NostrEvent[]
Dead code Fixed shortPubkey, findOrCreatePrivateRoom, createReactionGiftWraps removed
JSON outputs missing unread / editOf / replyTo Fixed Added to chat list and chat open JSON outputs
fetchKind10050 missing timeout Fixed maxWait: 10000 added
SIGTERM not handled Fixed Added to chat listen
Sent messages loaded out of order Fixed loadPersistedData inserts in chronological order
State file permissions Fixed writeFileSync uses mode: 0o600
chat listen --contact <name> filter mismatch Fixed filterPubKey correctly set to null when contact not found
chat open --json doesn't mark messages as read Fixed Read-marking logic moved before JSON exit
onclose crash risk Fixed !reasons guard added
unwrapGiftWrap swallowed seal errors Fixed Broad try/catch removed

✅ Positive Notes

  • NIP-17 gift wraps: Correctly implemented with wrapManyEvents and tagSelfGiftWraps.
  • NIP-06 derivation: Correct path m/44'/1237'/0'/0/0.
  • NIP-42 AUTH: Properly wired via SimplePool automaticallyAuth.
  • NIP-78 profile events: Correct parameterized replaceable format with d tags.
  • BCH address validation: Basic cashaddr format check is present in set-bch-address.
  • Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
  • Build & tests: tsc passes cleanly, all 20 existing tests pass.

Verdict

Approve with requested changes — the codebase is solid and the feature is well-architected. Only one critical issue remains: the chat listen crash when --contact is set to an unknown name. The fix is a one-line change (or a small ternary rewrite). Once that is addressed, the PR is ready to merge.

New%20session%20-%202026-06-23T13%3A48%3A26.665Z
opencode session  |  github run

@github-actions

Copy link
Copy Markdown

PR Review: feat: add Nostr chat via CLI

Overall Assessment: The PR is well-architected and in very good shape after multiple review rounds. The separation of concerns (commands → store → relay/chat/keys) is clean, the Nostr protocol implementation (NIP-06, NIP-17, NIP-42, NIP-78) is correct, and the build/tests are green. I verified the current codebase compiles with zero errors and all 20 existing tests pass.

There are no critical crash bugs remaining in the current code. However, I identified 1 functional bug and 2 moderate data/UX issues that should be addressed, plus a few minor items.


🔴 Critical Issues

None remaining in the current code. All prior critical bugs (npub decoding, unread persistence, prefix matching, edit handling, SIGTERM, tail parsing, etc.) are resolved.


🟡 Moderate Issues (should fix)

1. chat listen --contact <invalid-npub> silently filters to self instead of all conversations

File: src/commands/chat.ts (lines 576–582)

When opts.contact starts with npub1 but nip19Decode throws, filterPubKey retains its initial value (store.keys?.pubKeyHex). The user sees a warning ("Invalid npub") but the process continues filtering to only their own messages, not all conversations.

Fix: Reset filterPubKey to null in the catch block:

} catch {
  filterPubKey = null
}
if (!filterPubKey) {
  console.log(chalk.yellow(`\n   Invalid npub: ${opts.contact}. Watching all conversations.\n`))
}

2. RelayService.publish can report the same relay in both accepted and errors

File: src/nostr/relay.ts (lines 232–262)

The seen Set is scoped per-event. When publishing a batch of gift wraps (one per recipient), the same relay can accept the first event but reject the second. The relay then appears in both accepted and errors. The chat send success message prints accepted.length / (accepted.length + errors.length), which becomes misleading (e.g., "1/2 relays" for a single relay that partially accepted).

Fix: Track per-relay status across the entire batch. A Map<string, 'accepted' | 'rejected'> would give a clear summary.

3. displayNameCache becomes stale when a contact is renamed

File: src/nostr/store.ts (lines 429–435)

resolveDisplayName returns early if displayNameCache[pubKeyHex] is set. It never re-checks whether the contact's name has been updated since the cache was populated. The non-JSON chat list path uses getContactName (which checks contact.name first), so it is correct. But the JSON chat list path reads displayNameCache directly, producing stale data after a rename.

Fix: In resolveDisplayName, if a contact exists and its name differs from the cache, update the cache:

if (contact?.name && !contact.name.includes('...')) {
  if (this.displayNameCache[pubKeyHex] !== contact.name) {
    this.displayNameCache[pubKeyHex] = contact.name
    this.saveState()
  }
  return contact.name
}

4. room.subject is never populated from subject tags

File: src/nostr/store.ts (lines 273–295)

receiveMessage extracts replyTo and editOf from rumor.tags, but it ignores subject tags. For group chats initiated from the mobile app with a subject, the CLI will show room.subject as null even though the data is present in the event. This is a data-loss bug for group chat subjects.

Fix: Extract the subject tag when creating a room:

const subject = rumor.tags.find(t => t[0] === 'subject')?.[1] || null
room = { ... subject, ... }

5. chat set-bch-address rejects uppercase cashaddr

File: src/commands/chat.ts (line 464)

The cashaddr regex .../i only allows lowercase characters. Valid cashaddr addresses are case-insensitive, so an uppercase BITCOINCASH:... is rejected even though it is valid.

Fix: Convert to lowercase before testing, or update the regex to allow uppercase.

6. No way to start a new 1:1 conversation from the CLI

File: src/commands/chat.ts

chat send only works for existing rooms. A user who adds a contact with add-contact cannot message them until the contact messages first (or until the mobile app creates the room). A chat start <npub> command that computes the room ID and creates the room would close this gap.


🟢 Minor Issues

7. chat listen prints "Listening" before the WebSocket is established

The log message is emitted immediately after the synchronous store.subscribe() call. The connection is still opening in the background. If the relay is unreachable, the user sees the message followed by silence.

8. editOf semantic inversion

In receiveMessage, when an edit is applied, the original message's editOf field is set to the edit rumor's ID. Semantically editOf should mean "this message is an edit of X", not "this message was edited by X". Since the edit itself is not stored as a separate message, this pragmatic inversion is acceptable, but it is confusing for any JSON consumer.

9. Group room display names are arbitrary

getOtherMember returns the first non-self member (alphabetically first hex pubkey). For group chats, room.name is set to the first sender's display name. Both are poor heuristics for group chat UX.

10. No unit tests for the new chat feature

The 20 existing tests pass, but ChatStore, RelayService, and the chat command handlers have zero coverage. The store/room logic is testable with mocked relay interaction.

11. publish method has a redundant ternary

r.status === 'rejected' ? String(r.reason) : 'unknown error' is redundant because Promise.allSettled can only return fulfilled or rejected. The else branch is always rejected.


✅ Verification of Prior Review Fixes

Issue Status Evidence
chat listen --contact <npub> never matched Fixed chat.ts lines 576–582 decode npub1... to hex
Unread count never persisted Fixed chat.ts lines 145–149 save readMessageIds after chat open
chat open prefix matching Fixed store.ts getRoom() supports prefix matching
Message edits silent / not bumping room Fixed store.ts lines 294–310 updates room.updatedAt and triggers onNewMessage for edits
--tail 0 parsing Fixed chat.ts line 135 uses Number.isNaN with a guard for tail === 0
resolveDisplayName short-name caching Fixed store.ts line 432 uses !includes('...')
hexToBytes duplication Fixed All files import from nostr-tools/utils
Silent error swallowing Fixed catch blocks now log to stderr with console.error
Global singleton state Fixed relay.ts is now a proper RelayService class
publish + publishEvent duplication Fixed Single publish() API
Dead code Fixed shortPubkey, findOrCreatePrivateRoom, createReactionGiftWraps removed
JSON outputs missing unread / editOf / replyTo Fixed Added to chat list and chat open JSON outputs
fetchKind10050 missing timeout Fixed maxWait: 10000 added
SIGTERM not handled Fixed Added to chat listen
Sent messages loaded out of order Fixed loadPersistedData inserts in chronological order
State file permissions Fixed writeFileSync uses mode: 0o600
chat listen --contact <name> filter mismatch Fixed filterPubKey correctly set to null when contact not found
chat open --json doesn't mark messages as read Fixed Read-marking logic moved before JSON exit
onclose crash risk Fixed !reasons guard added
unwrapGiftWrap swallowed seal errors Fixed Broad try/catch removed

✅ Positive Notes

  • NIP-17 gift wraps: Correctly implemented with wrapManyEvents and tagSelfGiftWraps.
  • NIP-06 derivation: Correct path m/44'/1237'/0'/0/0.
  • NIP-42 AUTH: Properly wired via SimplePool automaticallyAuth.
  • NIP-78 profile events: Correct parameterized replaceable format with d tags.
  • Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
  • BCH address validation: Basic cashaddr format check is present in set-bch-address.
  • Build & tests: tsc passes cleanly, all 20 existing tests pass.

Verdict

Approve with requested changes — the codebase is solid and the feature is well-architected. The remaining functional issues are moderate and straightforward to fix:

  1. Invalid npub filter fallback (small catch block fix).
  2. Publish duplicate errors (per-relay tracking).
  3. Stale display name cache (check contact name before cache return).
  4. Missing subject extraction (one-line tag read).

All four are quick wins and would make the CLI more robust. The remaining structural items (group chat UX, unit tests, chat start command) can be addressed in follow-up PRs.

New%20session%20-%202026-06-23T13%3A53%3A24.480Z
opencode session  |  github run

@github-actions

Copy link
Copy Markdown

PR Review: feat: add Nostr chat via CLI

Overall Assessment: The PR is in excellent shape and ready to merge. The architecture (commands → store → relay/chat/keys) is clean, the Nostr protocol implementation (NIP-06, NIP-17, NIP-42, NIP-78) is correct, and the build/tests are green. I verified the current codebase compiles with zero errors and all 20 existing tests pass.


✅ Verification of Critical Fixes

All critical and moderate bugs from the extensive review history have been resolved in the current code:

Issue Status Evidence
chat listen --contact <npub> never matched Fixed chat.ts decodes npub1... to hex before filtering
chat listen --contact <invalid-npub> fallback Fixed Invalid npub now exits with error instead of filtering to self
chat listen --contact <name> crash Fixed filterPubKey correctly set to null when contact not found
Unread count never persisted Fixed chat.ts saves readMessageIds after chat open
chat open prefix matching Fixed store.ts getRoom() supports prefix matching
chat open --json doesn't mark messages as read Fixed Read-marking logic moved before JSON exit
Message edits silent / not bumping room Fixed store.ts updates room.updatedAt and triggers onNewMessage for edits
--tail 0 parsing Fixed chat.ts uses Number.isNaN with a guard for tail === 0
resolveDisplayName short-name caching Fixed store.ts updates cache when contact name changes
hexToBytes duplication Fixed All files import from nostr-tools/utils
Silent error swallowing Fixed catch blocks now log to stderr with console.error
Global singleton state Fixed relay.ts is now a proper RelayService class
publish + publishEvent duplication Fixed Single publish() API with relayStatus Map for deduplication
Dead code Fixed shortPubkey, findOrCreatePrivateRoom, createReactionGiftWraps removed
JSON outputs missing unread / editOf / replyTo Fixed Added to chat list, chat open, and chat listen JSON outputs
fetchKind10050 missing timeout Fixed maxWait: 10000 added
SIGTERM not handled Fixed Added to chat listen
Sent messages loaded out of order Fixed loadPersistedData inserts in chronological order
State file permissions Fixed writeFileSync uses mode: 0o600
onclose crash risk Fixed !reasons guard added
unwrapGiftWrap swallowed seal errors Fixed Broad try/catch removed; failures propagate
room.subject missing Fixed receiveMessage extracts subject tag
set-bch-address uppercase rejection Fixed Input is lowercased before regex validation
No way to start new conversation Fixed New chat start <npub> command added

🟡 Minor Issues (remaining, non-blocking)

  1. chat listen prints "Listening" before the WebSocket handshake completes
    The log is emitted immediately after the synchronous store.subscribe() call. The actual connection is still opening asynchronously. If the relay is unreachable, the user sees the listening message followed by silence. Consider deferring the log until the first onevent fires, or removing it.

  2. editOf semantic inversion is slightly confusing
    In receiveMessage, when an edit is applied to an existing message, the original message's editOf field is set to the edit rumor's ID (target.editOf = rumor.id). Semantically editOf should mean "this message is an edit of X", not "this message was edited by X". The pragmatic inversion is acceptable for a v1, but consider documenting it for JSON consumers.

  3. Group room display names are arbitrary
    getOtherMember returns the first non-self member (alphabetically first hex pubkey). For group chats, room.name is set to the first sender's display name. Both are poor heuristics for group chat UX. Acceptable for a v1, but worth improving later.

  4. No unit tests for the new chat feature
    The 20 existing tests still pass, but ChatStore, RelayService, and the chat command handlers have zero test coverage. The store/room logic is testable with mocked relay interaction.


✅ Positive Notes

  • NIP-17 gift wraps: Correctly implemented with wrapManyEvents and tagSelfGiftWraps.
  • NIP-06 derivation: Correct path m/44'/1237'/0'/0/0.
  • NIP-42 AUTH: Properly wired via SimplePool automaticallyAuth.
  • NIP-78 profile events: Correct parameterized replaceable format with d tags.
  • Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
  • BCH address validation: Basic cashaddr format check is present in set-bch-address.
  • Build & tests: tsc passes cleanly, all 20 existing tests pass.

Verdict

Approve — all critical and moderate functional issues from previous reviews are resolved. The codebase is solid, the feature is well-architected, and the remaining items are minor UX improvements and test coverage that can be addressed in follow-up PRs.

New%20session%20-%202026-06-23T14%3A18%3A02.731Z
opencode session  |  github run

joemarct and others added 2 commits June 23, 2026 22:59
@github-actions

Copy link
Copy Markdown

PR Review: feat: add Nostr chat via CLI

Overall Assessment: The PR is well-architected and now compiles cleanly with all tests passing. The separation of concerns (commands → store → relay/chat/keys) is clean, and the Nostr protocol implementation (NIP-06, NIP-17, NIP-42, NIP-78) is correct. I applied fixes during this review to resolve TypeScript compilation errors and test failures in the new test suite.


✅ Fixed During This Review

Issue Fix
Build failure: store.test.ts TypeScript errors (4 errors) importOriginal cast to any; added import type { ChatStore as ChatStoreType } to avoid value/type conflict; added missing nsec to mockKeys
Test failure: loadPersistedData tests failed because os.homedir() was not mocked Added vi.mock('os', ...) to return a deterministic test home directory so STATE_FILE resolves correctly
Test count mismatch With the fixes, all 70 tests pass (up from 68 in the last review, because the previously broken store.test.ts tests now run)

✅ Verification of Prior Review Fixes

Issue Status Evidence
chat listen --contact <npub> never matched Fixed chat.ts lines 603–609 decode npub1... to hex
chat listen --contact <invalid-npub> fallback Fixed Invalid npub exits with error (line 608)
chat listen --contact <name> crash Fixed filterPubKey set to null when contact not found (line 613)
Unread count never persisted Fixed chat.ts lines 145–149 save readMessageIds after chat open
chat open prefix matching Fixed store.ts getRoom() supports prefix matching (line 389)
chat open --json doesn't mark messages as read Fixed Read-marking logic moved before JSON exit (lines 144–149)
Message edits silent / not bumping room Fixed store.ts updates room.updatedAt and triggers onNewMessage (lines 301–310)
--tail 0 parsing Fixed chat.ts line 135 uses Number.isNaN with a guard for tail === 0
resolveDisplayName short-name caching Fixed store.ts line 455 uses !includes('...')
hexToBytes duplication Fixed All files import from nostr-tools/utils
Silent error swallowing Fixed catch blocks now log to stderr with console.error
Global singleton state Fixed relay.ts is now a proper RelayService class
publish + publishEvent duplication Fixed Single publish() API with per-relay status tracking
Dead code Fixed Removed unused functions
JSON outputs missing unread / editOf / replyTo Fixed Added to all JSON outputs
fetchKind10050 missing timeout Fixed maxWait: 10000 added
SIGTERM not handled Fixed Added to chat listen (line 673)
Sent messages loaded out of order Fixed loadPersistedData inserts in chronological order (lines 111–113)
State file permissions Fixed writeFileSync uses mode: 0o600 (line 135)
onclose crash risk Fixed !reasons guard added (line 153)
unwrapGiftWrap swallowed seal errors Fixed Failures propagate correctly
room.subject missing Fixed receiveMessage extracts subject tag (line 285)
set-bch-address uppercase rejection Fixed Input is lowercased before regex validation (line 491)
No way to start new conversation Fixed New chat start <npub> command added (lines 247–271)

🟡 Minor Issues (remaining, non-blocking)

  1. chat listen prints "Listening" before the WebSocket handshake completes
    The log is emitted immediately after the synchronous store.subscribe() call. The connection is still opening asynchronously. If the relay is unreachable, the user sees the listening message followed by silence. This is a minor UX papercut.

  2. editOf semantic inversion is slightly confusing
    In receiveMessage, when an edit is applied to an existing message, the original message's editOf field is set to the edit rumor's ID (target.editOf = rumor.id). Semantically editOf should mean "this message is an edit of X", not "this message was edited by X". The pragmatic inversion is acceptable for a v1, but worth documenting for JSON consumers.

  3. Group room display names are arbitrary
    getOtherMember returns the first non-self member (alphabetically first hex pubkey). For group chats, room.name is set to the first sender's display name. Both are poor heuristics for group chat UX. Acceptable for a v1.

  4. Heavy process.exit usage in chat.ts makes unit testing impossible
    All command handlers call process.exit directly. Consider throwing typed errors and letting the top-level program.parse() catch block handle exit codes in a follow-up.


✅ Positive Notes

  • NIP-17 gift wraps: Correctly implemented with wrapManyEvents and tagSelfGiftWraps.
  • NIP-06 derivation: Correct path m/44'/1237'/0'/0/0.
  • NIP-42 AUTH: Properly wired via SimplePool automaticallyAuth.
  • NIP-78 profile events: Correct parameterized replaceable format with d tags.
  • Relay-first design: Rooms and messages are reconstructed fresh each session, matching the app behavior.
  • BCH address validation: Basic cashaddr format check is present in set-bch-address.
  • Test coverage: New chat.test.ts and store.test.ts add 50 tests covering room IDs, kind 14 creation, gift wrap tagging, kind 10050, store persistence, message receiving, edits, contacts, and subscription lifecycle.
  • Build & tests: tsc passes with zero errors, all 70 tests pass.

Verdict

Approve — the critical functional issues from previous reviews are resolved, and the TypeScript compilation and test failures I found during this review are now fixed. The codebase is solid, the feature is well-architected, and the remaining items are minor UX improvements that can be addressed in follow-up PRs.

New%20session%20-%202026-06-23T14%3A59%3A43.396Z
opencode session  |  github run

@github-actions

Copy link
Copy Markdown

User github-actions[bot] does not have write permissions

github run

@joemarct joemarct merged commit c5e6a4f into master Jun 23, 2026
1 check failed
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