34 Commits

Author SHA1 Message Date
adlee-was-taken
b2749826b1 docs: CHANGELOG entry for v0.5.0
Three release trains roll into one tag — v0.3.0 backup/restore +
LastPass import, v0.4.0 device authentication, and the v0.5.0
polish + harden bundle.

Renames the existing "Unreleased" heading to v0.5.0 — 2026-05-02
and prepends the polish + harden additions:

- Security: S1 pre-receive hook fix (HIGH-severity authentication
  bypass), S2 tar-restore path-traversal hardening, S3 RELICARIO_*
  env-var audit + cfg-gate.
- Fixed: B1 strength-meter regenerate desync, B2/P4 raw error-code
  leakage in the fullscreen tab.
- Added: P1 password coloring (four reveal surfaces + settings UI),
  P2 setup → fullscreen vault tab handoff. Existing v0.3.0/v0.4.0
  Added entries (sync, register-from-popup, generator-defaults, edit
  TOTP, history, detach, status, backup/restore, vault-tab panel,
  LastPass import + popup deep link, status export age) preserved
  verbatim.
- Changed: P3 form-layout envelope, doc-audit refresh across
  overview / CLAUDE / SECURITY / ARCHITECTURE / foundational spec.
- Internal: C1 stale-branch prune, clippy cleanup, Cargo.lock
  regenerated, CLI/extension refactors preserved from prior trains.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 20:48:22 -04:00
adlee-was-taken
a332a9e80d Merge feature/v0.5.0-plan-a-security-cleanup: Plan A security + cleanup
v0.5.0 Plan A — Security Fixes + Repo Cleanup. 7 commits, ~800 net
insertions across the Rust workspace. Four items delivered:

- S1 (HIGH-severity authentication bypass fix): rewrite verify_commit
  in relicario-server. The previous implementation accepted any
  GOODSIG/Good signature line on stderr, ignoring whether the signing
  key was registered or revoked. The new implementation:
  * builds a temp gpg.ssh.allowedSignersFile from devices.json at the
    commit (no global git-config mutation)
  * parses the SHA-256 fingerprint from `git verify-commit --raw`
    stderr via regex
  * checks revocation FIRST (revoked entries may have been removed
    from devices.json), with the historical-commit case
    (committer_ts < revoked_at) explicitly allowed
  * uses committer date (GIT_COMMITTER_DATE / `git show -s
    --format=%ct`), not author date or wall clock
  * tightened the bootstrap guard to require BOTH devices and revoked
    to be empty (closes an empty-devices.json privilege-escalation
    route present in the original code)
  * 4 acceptance integration tests build real on-disk repos with
    SSH-signed commits and verify each scenario

- S2 (tar archive path-traversal hardening): replace
  tar::Archive::unpack with safe_unpack_git_archive. Located in
  relicario-core (per-spec, so integration tests can reach it without
  the bytes-in/bytes-out invariant breaking). Validates each entry's
  type (rejects symlinks/hardlinks), path components (rejects '..',
  RootDir, Windows drive Prefix), and declared size (rejects
  individual or cumulative > 100×compressed-or-1-GiB whichever is
  lower). The CLI's restore path adds a paranoid OS-level
  starts_with(.git/) check on the joined destination as
  defense-in-depth even after textual validation. 5 acceptance tests
  cover path traversal, symlinks, oversized headers (header claim of
  2 GiB tested without allocating disk).

- S3 (RELICARIO_* env-var audit): docs/SECURITY.md gains a
  "Configuration env vars" section enumerating each variable, its
  purpose, and trust assumption. Active-in-all-builds variables
  (RELICARIO_IMAGE, RELICARIO_GITEA_*) are documented; debug-only
  variables (RELICARIO_NO_GROUPS_CACHE, RELICARIO_TEST_*) are gated
  behind cfg(debug_assertions) so the env-var lookup is removed from
  --release binaries.

- C1 (stale feature branch prune): 5 merged feature branches and
  3 worktrees pruned interactively per dev report.

- Bonus: 4d02a50 fixes pre-existing clippy warnings across
  crates/relicario-{core,cli} (deref operators, Option::is_none_or
  vs map_or(true, ...), iter_mut().enumerate() patterns,
  div_ceil()) so the workspace builds clean under `-D warnings`.

Merge resolution: docs/SECURITY.md had a conflict where main's F11/F12
(Device Authentication paragraph naming relicario-server + simplified
"Device registration is optional" line) collided with Plan A's S3
section. Resolved by keeping both — F11/F12's wording for the
Device Authentication section, then Plan A's "Configuration env vars"
section appended below.

Cargo.lock regenerated. The previous committed lock was stale since
commit 8855078 (--totp-qr); cargo test on both devs' worktrees
produced identical regenerated locks. Plan A genuinely added regex +
tempfile to relicario-server (both already transitively present from
relicario-cli), so no new top-level deps; the Cargo.lock churn is
catch-up of crate-version bumps that have happened since the last
commit-of-record.

Tests: 248 cargo tests pass; extension tests unchanged (336/8 with 8
pre-existing device-auth scaffolding failures).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:54:12 -04:00
adlee-was-taken
d45dd10917 Merge feature/v0.5.0-plan-b-extension-ux: Plan B extension UX
v0.5.0 Plan B — Extension UX Polish + Bug Fixes. 15 commits, 22 files,
+853/-33 lines, all in extension/. Five features delivered:

- P4: ERROR_COPY centralized map; popup humanizeError now a thin shell
  over lookupErrorCopy; fullscreen tab gets friendly title/body/CTA blocks
  (closes B2). Generated test enumerates every grep'd error code so the
  registry can't drift.
- B1: applyGeneratedPassword dispatches a synthetic input event after
  the regenerate handler sets the password value, so the strength-meter
  listener re-rates the new value.
- P1: end-to-end password coloring — pure colorizePassword utility,
  chrome.storage.sync round-trip via applyColorScheme, CSS rules with
  custom properties, four reveal surfaces (popup item-detail, vault
  item-detail, field-history, generator preview), boot wiring + storage
  listener, Display section in settings with color pickers + swatch +
  reset.
- P3: .form-lower wrapper constrains lower form sections (notes,
  custom-fields, attachments, actions) to the same max-width: 960px
  envelope as .form-grid above, gated on surface === 'fullscreen' so
  the popup is unaffected.
- P2: finishSetup() opens the fullscreen vault tab and best-effort
  closes the setup tab after successful device registration. Both
  create-new and attach-existing flows funnel through it.

Implementation notes:
- vault.ts uses event delegation on the stable #vault-app root for
  .error-cta clicks (better than the plan's per-render handler attach;
  survives re-renders without leaking listeners).
- fields.ts gained a kind: 'password' | 'concealed' option on
  ConcealedRowOpts so wireFieldHandlers can apply colorizePassword
  selectively at the shared rendering layer.
- New WASM stub at src/__stubs__/relicario_wasm.stub.ts + vitest config
  alias lets unit tests import setup.ts without exploding on the
  runtime-only WASM module.

Tests: +28 (336/8 vs main's 308/8); 8 pre-existing device-auth
scaffolding failures unchanged. Builds clean: cargo wasm + Chrome
bundle + Firefox bundle.

Manual acceptance items (P3 viewport sweep at 1920/1440/1024/768,
P2 setup-flow smoke) deferred to user's pre-tag smoke walk.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:42:22 -04:00
adlee-was-taken
4e9d834920 feat(ext/setup): hand off completion to fullscreen vault tab (P2)
After successful device registration (state.configPushed = true), the
wizard now opens vault.html in a new tab and closes the setup tab.
Both create-new and attach-existing flows funnel through the same
finishSetup() handler. Closing the setup tab is best-effort --
chrome.tabs.remove failures don't block the vault open.

Add src/__stubs__/relicario_wasm.stub.ts + vitest.config alias so
setup.ts can be imported in unit tests without the runtime WASM file.
Exclude the stubs dir from the webpack/tsc build in tsconfig.json.
2026-05-02 19:15:35 -04:00
adlee-was-taken
631e9af470 fix(ext/login): constrain lower form sections to .form-grid envelope (P3)
Notes, custom-fields disclosure, attachments disclosure, and form-actions
in fullscreen logins now sit inside a .form-lower wrapper with the same
max-width: 960px; margin: 0 auto envelope as .form-grid above. Removes
the visual rhythm break at the 2-col -> full-width transition.

Popup keeps its current single-column behavior (gated on surface flag).
2026-05-02 19:07:33 -04:00
adlee-was-taken
b2fc56709a feat(ext/settings): Display section with color pickers + swatch + reset
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:03:23 -04:00
adlee-was-taken
b928ed407b feat(ext): apply color scheme on popup + vault startup
Import applyColorScheme in popup.ts and vault.ts, await it at boot,
and register a chrome.storage.onChanged listener so live color-picker
changes take effect without a reload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 18:55:28 -04:00
adlee-was-taken
5d9a7ee8d3 docs(multi-agent): expand kickoff section with full spec→plans→launch workflow 2026-05-02 18:51:52 -04:00
adlee-was-taken
6bca0b3526 feat(ext/popup/item-detail): colorize revealed password field
Add data-field-kind attribute to renderConcealedRow so wireFieldHandlers
can distinguish password fields from other concealed rows (TOTP secrets,
CVV, PIN, private keys). Apply colorizePassword() on reveal when kind is
"password"; plain textContent otherwise. Pass kind through renderSections
for custom-section password fields.
2026-05-02 18:49:56 -04:00
adlee-was-taken
f45c275566 feat(ext/generator): colorize live password preview 2026-05-02 18:49:56 -04:00
adlee-was-taken
3e4312ca6f feat(ext/popup/field-history): colorize revealed password entries
Import colorizePassword and post-process .revealed value cells after
innerHTML render, replacing escaped-HTML text with colored spans via
the valueStore plaintext lookup.
2026-05-02 18:47:12 -04:00
adlee-was-taken
4fc1357368 docs: add multi-agent development paradigm README 2026-05-02 17:19:25 -04:00
adlee-was-taken
518b41e9cd style(ext): add password-coloring CSS rules + custom property defaults 2026-05-02 17:19:06 -04:00
adlee-was-taken
df58b0dda1 chore(relay): add relay MCP server to project Claude config 2026-05-02 17:18:47 -04:00
adlee-was-taken
ed9fcbe6ba feat(relay): start.sh launcher with --manual/--tmux/--kitty modes 2026-05-02 17:18:31 -04:00
adlee-was-taken
0172a06698 feat(relay): MCP SSE server with post_message/read_messages/list_pending 2026-05-02 17:17:41 -04:00
adlee-was-taken
1de7cda1b0 feat(ext/shared): add colorizePassword utility
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 17:17:05 -04:00
adlee-was-taken
6d5a2570d4 feat(relay): in-memory queue with consume-once semantics 2026-05-02 17:16:21 -04:00
adlee-was-taken
6d8f699fcb chore(relay): scaffold tools/relay with MCP SDK dep 2026-05-02 17:15:46 -04:00
adlee-was-taken
25c9eb52a0 feat(ext/shared): color-scheme storage + applyColorScheme
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:49:03 -04:00
adlee-was-taken
2df636e454 fix(ext/login): dispatch input event after regenerate sets password (B1)
Programmatic input.value = newPassword does not fire input events, so
the strength-meter listener at shared/form-affordances/password-tools.ts:65
never re-rates the new value — meter stays stuck on the prior reading.

Extract applyGeneratedPassword(input, value) helper that sets value, type,
then dispatches new InputEvent('input', { bubbles: true }). Vitest covers
the dispatch + a sanity check that bubbling listeners fire.
2026-05-02 16:46:06 -04:00
adlee-was-taken
c0921b134d docs(plan): relay server implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:44:06 -04:00
adlee-was-taken
575343dc19 refactor(ext/vault): event delegation for error-cta + CSS variable consistency 2026-05-02 16:41:39 -04:00
adlee-was-taken
0443f6a3b4 docs(spec): add top-level README section to relay server design
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:38:30 -04:00
adlee-was-taken
5e8e617a4d docs(spec): relay server design for multi-agent message bus
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:37:17 -04:00
adlee-was-taken
1c641b4911 fix(ext/vault): friendly error block in fullscreen tab (closes B2)
Replaces raw escapeHtml(state.error) renders with lookupErrorCopy()-driven
title/body/CTA blocks. vault_locked specifically gets an 'Unlock vault'
CTA that refocuses the passphrase input. Other CTAs route to setup.html
or chrome.runtime.reload().

Closes B2; concludes P4.
2026-05-02 16:37:16 -04:00
adlee-was-taken
214e1e49f8 test(ext/shared): pin fallback title assertion in error-copy test 2026-05-02 16:30:09 -04:00
adlee-was-taken
af8626fb5f docs(audit): mark all 8 proposed findings fixed (PM follow-up)
Updates each Status: line from "Proposed; needs user decision" to
the actual fix-commit SHA. The audit doc now records the full state:
6 trivial findings fixed in the initial 900ccf1 pass; 8 deeper
findings fixed across ca059e7, 8fd9a05, 1342228, 76d092d, 9c97f9f
during v0.5.0 PM kickoff.

Pre-tag checklist: doc-audit follow-ups item is now done.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:28:08 -04:00
adlee-was-taken
9c97f9f939 docs(spec): banner foundational design spec as historical (audit F13)
The 2026-04-11 design spec lists secure notes, secure documents, TOTP,
Firefox extension, LastPass import, and device authentication as
"Post-V1 Ideas" — most of which shipped over the following weeks.
Per the doc/architecture/overview.md convention, specs are frozen
decision artifacts and shouldn't be retro-edited; instead, add a
one-line status banner pointing readers at CHANGELOG.md and the
overview doc for current state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:26:39 -04:00
adlee-was-taken
76d092d4f6 docs(architecture): note settings.enc + typed items in vault-creation flow (audit F10)
The Vault Creation Flow ASCII showed only manifest.enc as init's
encrypted artifact; cmd_init has been writing settings.enc in parallel
since the VaultSettings rollout. Update the encrypt step to show both
artifacts side-by-side with independent nonces.

Below the ASCII, add a short pointer noting that the per-item lifecycle
(typed-item envelope, attachment encryption, field-history) lives in
crates/relicario-core/ARCHITECTURE.md and reuses the same master_key +
XChaCha20-Poly1305 primitives. The doc-audit framing is "this top-level
doc could just point at the per-crate docs" — taking that trim path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:26:09 -04:00
adlee-was-taken
648dcf386e feat(ext/shared): centralize error-message copy in ERROR_COPY map
Replaces the popup's regex-chain humanizeError with a total lookup over
every error code returned by extension/src/service-worker/router/. A
generated test discovers codes via grep so the registry can't drift.
The popup keeps its small set of regex translators for Rust/serde error
phrasing that doesn't go through the router's error vocabulary.

Subsumes B2 — fullscreen consumer lands in the next commit.
2026-05-02 16:26:01 -04:00
adlee-was-taken
1342228a51 docs(security): name relicario-server in device-auth section (audit F11/F12)
- F12: Device Authentication section now names the relicario-server crate
  and its two subcommands (generate-hook, verify-commit), and notes that
  signed commits without the server-side hook provide authorship only —
  any pusher can still land an unsigned commit.
- F11: drop the "optional before v0.4.0" version line (v0.4.0 was never
  tagged; v0.5.0 is the first release with the hook) and replace with a
  one-liner: registration is optional but recommended for shared vaults.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:25:21 -04:00
adlee-was-taken
8fd9a05875 docs(claude): refresh project tree, IDs line, and roadmap (audit F2/F3/F4)
- F2: add relicario-server crate to the project-structure tree
- F3: replace stale "Next: WASM + Chrome MV3 (Plan 2)" roadmap line with
  the v0.5.0/Phase-3/1C-γ/LastPass picture
- F4: ItemIds and FieldIds are 16-char hex (64 bits) per audit M8;
  AttachmentIds are first 32 hex of SHA-256 (128 bits) per audit I2/B4

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:23:17 -04:00
adlee-was-taken
ca059e7507 docs(overview): add relicario-server crate to four-codebase framing
Doc-audit Finding 1. The repo has had four Rust crates since early May
when the pre-receive hook crate landed, but docs/architecture/overview.md
still framed itself around three. Update:

- "The three codebases" → "The four codebases" (intro + heading)
- ASCII diagram fans core out to cli + server + wasm, with wasm feeding
  the extension
- Table gains a relicario-server row noting it lives on the git server
  and only sees public key material
- Build matrix adds `cargo build -p relicario-server --release`
- "Where to look next" points at server src + the device-auth design spec

Server has no user-facing surface, so the CLI/extension parity rule is
clarified to exclude it (it is server-side enforcement of an invariant
the clients already agreed to).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 16:20:45 -04:00
42 changed files with 5422 additions and 93 deletions

View File

@@ -1,4 +1,10 @@
{ {
"mcpServers": {
"relay": {
"type": "sse",
"url": "http://localhost:7331/sse"
}
},
"enabledPlugins": { "enabledPlugins": {
"superpowers@claude-plugins-official": true "superpowers@claude-plugins-official": true
} }

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ extension/dist-firefox/
extension/wasm/ extension/wasm/
reference.jpg reference.jpg
ref.jpg ref.jpg
tools/relay/node_modules/

View File

@@ -1,9 +1,72 @@
# Changelog # Changelog
## Unreleased ## v0.5.0 — 2026-05-02
Three release trains roll into one tag — backup/restore + LastPass
import (originally v0.3.0), device authentication (originally v0.4.0),
and the v0.5.0 polish + harden bundle (security fixes + UX fixes +
two confirmed bugs).
### Security
- **Pre-receive hook now actually verifies signatures (audit S1, HIGH).**
Earlier `relicario-server` builds accepted any commit with a
`Good signature` line on stderr regardless of which key signed it —
device-auth was a no-op. The hook now builds an `allowed_signers`
file from `devices.json` at the commit (via `GIT_CONFIG_*` env, no
global git-config mutation), parses the SSH SHA-256 fingerprint out
of `git verify-commit --raw` stderr, and rejects unregistered keys or
revoked keys whose committer-date is at or after the revocation
timestamp. Bootstrap mode is preserved only when **both**
`devices.json` AND `revoked.json` are empty (closes an
empty-devices.json privilege-escalation route).
- **Backup-restore tar unpacking hardened (audit S2).** `relicario
backup restore` no longer trusts `tar::Archive::unpack`'s defaults.
A new `relicario_core::safe_unpack_git_archive` validates each
entry's path components (rejects `..`, absolute paths, Windows
drive prefixes), rejects symlinks/hardlinks, and caps total
uncompressed size at the lower of 100×compressed-bytes or 1 GiB.
The CLI restore path adds a paranoid `dest.starts_with(.git/)`
check after path-joining as defense-in-depth.
- **`RELICARIO_*` env-var surface audited (audit S3).** `docs/SECURITY.md`
gains a per-variable trust table. `RELICARIO_NO_GROUPS_CACHE` (a
developer escape hatch, not a user knob) is now
`cfg(debug_assertions)`-gated and is a no-op in `--release` builds;
the env-var lookup is removed from the binary by the optimiser.
### Fixed
- **Strength meter no longer goes stale after the regenerate button (B1).**
Programmatic `input.value = newPassword` doesn't fire `input`
events; the regenerate handler now dispatches a synthetic
`InputEvent('input', { bubbles: true })` so the meter listener
re-rates the new value.
- **Snake_case error codes no longer leak into the UI (B2 / P4).**
Errors like `vault_locked`, `origin_mismatch`, `unauthorized_sender`
used to render verbatim in the fullscreen vault tab and (in some
cases) the popup. New `extension/src/shared/error-copy.ts` central
registry maps every service-worker error code to friendly
title/body/CTA copy; the popup and fullscreen tab consume the
same map. The fullscreen lock screen's `vault_locked` block now
reads `Vault locked / Unlock your vault to continue. / [Unlock
vault]`. A generated test enumerates the live error codes via
grep so the registry can't drift.
### Added ### Added
- **Password coloring (P1).** Revealed passwords in the popup
item-detail, fullscreen item view, field-history viewer, and
generator preview render digits and symbols in distinct colors.
Defaults: blue digits, red symbols. Users can override via the
new Display section in settings (color pickers + live preview
swatch + reset). Defaults round-trip via
`chrome.storage.sync.password_display_scheme`; cross-device when
Chrome sync is enabled.
- **Setup wizard hands off to the fullscreen vault tab on completion
(P2).** Both create-new and attach-existing flows now open
`vault.html` in a new tab and best-effort close the setup tab
after device registration succeeds — replaces the prior
setup-tab-stays-open terminal screen.
- **Sync now button** in the extension settings view — surfaces the - **Sync now button** in the extension settings view — surfaces the
previously hidden `{ type: 'sync' }` SW message to users with success / previously hidden `{ type: 'sync' }` SW message to users with success /
error feedback. error feedback.
@@ -59,6 +122,30 @@
file `cmd_backup_export` writes on success). Reads "never" for file `cmd_backup_export` writes on success). Reads "never" for
fresh vaults, "4 days ago" otherwise. fresh vaults, "4 days ago" otherwise.
### Changed
- **Form layout in the fullscreen vault tab is now visually consistent
(P3).** Notes, custom-fields disclosure, attachments disclosure, and
form-actions in fullscreen logins now sit inside a `.form-lower`
wrapper with the same `max-width: 960px; margin: 0 auto` envelope as
the `.form-grid` cards above. Removes the visual rhythm break at the
2-col → full-width transition. The popup surface is unchanged.
- **Documentation refreshed for v0.5.0 (doc audit, 14 findings).**
`docs/architecture/overview.md` now describes four codebases (the
`relicario-server` pre-receive hook crate is no longer invisible);
`CLAUDE.md` project tree and roadmap reflect current state;
`docs/SECURITY.md` names the server crate and its `verify-commit` /
`generate-hook` subcommands and notes the without-the-hook-it's-
advisory caveat; `docs/ARCHITECTURE.md` shows `settings.enc` as a
parallel artifact in the vault-creation flow; the foundational
design spec gains a "historical" status banner pointing readers at
the current docs.
- `relicario generate` now consults `VaultSettings.generator_defaults` when
invoked inside an initialized vault. Explicit flags (`--length`,
`--bip39`, `--words`, `--symbols`, `--separator`) override the vault
default. Outside a vault, behavior is unchanged (length 20, safe symbol
set, 5 BIP39 words, space separator).
### Known limitations ### Known limitations
- **Mid-restore failure leaves the target remote in a half-written - **Mid-restore failure leaves the target remote in a half-written
@@ -74,6 +161,13 @@
### Internal ### Internal
- 5 stale local feature branches and 3 worktrees pruned (audit C1).
- Pre-existing clippy warnings cleaned up across `relicario-{core,cli}`
(deref operators, `Option::is_none_or` over `map_or(true, ...)`,
`iter_mut().enumerate()` patterns, `div_ceil()`) so the workspace
builds clean under `-D warnings`.
- `Cargo.lock` regenerated and committed; was stale since the
`--totp-qr` commit.
- Refactored `cmd_add` and `cmd_edit` in the CLI: each `ItemCore` variant - Refactored `cmd_add` and `cmd_edit` in the CLI: each `ItemCore` variant
now has its own `build_*_item` / `edit_*` helper. Pure mechanical now has its own `build_*_item` / `edit_*` helper. Pure mechanical
extraction; behavior unchanged. The dispatcher matches and delegates. extraction; behavior unchanged. The dispatcher matches and delegates.
@@ -83,14 +177,6 @@
`setup.ts` since it walks live wizard state. Setup.ts went from `setup.ts` since it walks live wizard state. Setup.ts went from
1205 → 1137 lines. 1205 → 1137 lines.
### Changed
- `relicario generate` now consults `VaultSettings.generator_defaults` when
invoked inside an initialized vault. Explicit flags (`--length`,
`--bip39`, `--words`, `--symbols`, `--separator`) override the vault
default. Outside a vault, behavior is unchanged (length 20, safe symbol
set, 5 BIP39 words, space separator).
## v0.2.0 — 2026-04-27 ## v0.2.0 — 2026-04-27
### Fixed ### Fixed

View File

@@ -48,9 +48,11 @@ crates/
│ ├── src/helpers.rs # vault_dir, git_command, iso8601 │ ├── src/helpers.rs # vault_dir, git_command, iso8601
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing) │ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection │ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
── relicario-wasm/ # WASM bindings for the extension ── relicario-wasm/ # WASM bindings for the extension
├── src/lib.rs # #[wasm_bindgen] surface ├── src/lib.rs # #[wasm_bindgen] surface
└── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]> └── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
└── relicario-server/ # `relicario-server` binary (pre-receive Git hook)
└── src/main.rs # verify-commit + generate-hook subcommands
``` ```
## Key design decisions ## Key design decisions
@@ -76,7 +78,7 @@ passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
- Tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever. - Tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever.
- Test JPEGs are generated synthetically via `make_test_jpeg()` — no binary test fixtures. - Test JPEGs are generated synthetically via `make_test_jpeg()` — no binary test fixtures.
- Item IDs are random 8-char hex strings. - Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256 over the plaintext (128 bits).
- Git history is preserved as an audit log — no squashing. - Git history is preserved as an audit log — no squashing.
- The CLI shells out to `git` for sync — no libgit2/gitoxide dependency. - The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.
@@ -90,4 +92,4 @@ Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2
## Roadmap ## Roadmap
Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM). Next: v0.5.0 polish + harden (in progress). After that, Phases 3/4 of the fullscreen UX redesign (vault-tab shell + command palette), Plan 1C-γ (attachments + Document + trash/history/device UI), and the LastPass importer. Mobile (Rust core compiles to ARM) and recovery QR remain on the roadmap.

940
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -83,8 +83,9 @@ vault_salt ────────►│ │
┌──────────────────┐ ┌──────────────────┐
master_key ────────►│ XChaCha20- │──────► manifest.enc master_key ────────►│ XChaCha20- │──────► manifest.enc
empty manifest ────►│ Poly1305 │ empty manifest ────►│ Poly1305 │ settings.enc
└──────────────────┘ default settings ──►│ encrypt (×2) (parallel artifacts;
└──────────────────┘ independent nonces)
┌──────────────────┐ ┌──────────────────┐
│ git init │──────► vault repo │ git init │──────► vault repo
@@ -92,6 +93,14 @@ empty manifest ────►│ Poly1305 │
└──────────────────┘ └──────────────────┘
``` ```
Item creation, the typed-item envelope (`Item` + per-type `ItemCore`),
attachment encryption, and field-history tracking are not shown above —
they are described in [`crates/relicario-core/ARCHITECTURE.md`](../crates/relicario-core/ARCHITECTURE.md).
The flow above covers only the crypto-pipeline shape that vault init
establishes; the per-item lifecycle reuses the same `master_key` +
XChaCha20-Poly1305 primitives against `items/<id>.enc` and
`attachments/<item-id>/<aid>.enc`.
## Unlock Flow (every vault operation) ## Unlock Flow (every vault operation)
``` ```

View File

@@ -48,6 +48,19 @@ When enabled, device authentication provides:
- **Push access control**: Deploy keys managed via Gitea API - **Push access control**: Deploy keys managed via Gitea API
- **Instant revocation**: One command cuts off both signing and push - **Instant revocation**: One command cuts off both signing and push
Enforcement requires deploying the `relicario-server` pre-receive hook
on the vault remote. The crate provides two subcommands:
- `relicario-server generate-hook` — emits the hook script to install at
`<repo>/hooks/pre-receive`
- `relicario-server verify-commit <sha>` — checks one commit's signature
against `.relicario/devices.json` and `.relicario/revoked.json` as of
that commit; the hook calls this for every pushed ref
Without the server hook, signed commits provide authorship metadata only
— any process with push access can land an unsigned commit, since
verification is otherwise advisory.
See `docs/superpowers/specs/2026-05-02-device-authentication-design.md`. See `docs/superpowers/specs/2026-05-02-device-authentication-design.md`.
## Access Control ## Access Control
@@ -57,8 +70,7 @@ Without device authentication, access control is transport-layer only:
- **CLI**: SSH key authentication to git remote - **CLI**: SSH key authentication to git remote
- **Extension**: Git credentials in browser storage - **Extension**: Git credentials in browser storage
Device registration was optional before v0.4.0. With device auth enabled, Device registration is optional but recommended for shared vaults.
all commits must be signed by a registered device.
## Configuration env vars ## Configuration env vars

View File

@@ -1,6 +1,6 @@
# Architecture overview — Relicario # Architecture overview — Relicario
This is the cross-codebase entry point. It describes how the three Relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is **deliberately thin**; the deep content lives in per-codebase docs. This is the cross-codebase entry point. It describes how the four Relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is **deliberately thin**; the deep content lives in per-codebase docs.
> If you are about to make a change in a single codebase, read its `ARCHITECTURE.md` first: > If you are about to make a change in a single codebase, read its `ARCHITECTURE.md` first:
> >
@@ -10,7 +10,7 @@ This is the cross-codebase entry point. It describes how the three Relicario cod
> >
> If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*. > If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*.
## The three codebases ## The four codebases
``` ```
┌─────────────────────┐ ┌─────────────────────┐
@@ -18,36 +18,40 @@ This is the cross-codebase entry point. It describes how the three Relicario cod
│ (Rust, no I/O) │ │ (Rust, no I/O) │
│ crypto · items │ │ crypto · items │
│ manifest · stego │ │ manifest · stego │
└──────────┬──────────┘ │ device keys + fp │
└──┬───────────┬──────┘
│ │
┌────────────────┼───────────┴──────┬────────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌────────────────┐ ┌──────────────────┐ ┌────────────────────┐
│ relicario-cli │ │ relicario-server │ │ relicario-wasm │
│ (Rust binary) │ │ (Rust binary) │ │ (#[wasm_bindgen] │
│ │ │ │ │ bindings) │
│ filesystem + │ │ pre-receive hook │ │ │
│ git + │ │ verify-commit + │ │ compiled to WASM │
│ clap UX │ │ generate-hook │ │ for the extension │
└────────────────┘ └──────────────────┘ └──────────┬─────────┘
┌─────────────────────┼─────────────────────┐
│ │ ┌─────────────────────┐
extension
┌────────────────┐ ┌────────────────────┐ (compiled to WASM │ (TypeScript) │
│ relicario-cli relicario-wasm │ inside the ) │ popup · vault
│ (Rust binary) (#[wasm_bindgen] │ extension │ setup · content
bindings) service worker
│ filesystem + │ │ │ │
│ git + │ └────────┬───────────┘ │
│ clap UX │ │ │
└────────────────┘ ▼ │
┌─────────────────────┐ │
│ extension │ │
│ (TypeScript) │ │
│ popup · vault │ │
│ setup · content │ │
│ service worker │ │
└─────────────────────┘ └─────────────────────┘
``` ```
| Codebase | Language | Role | Key boundary | | Codebase | Language | Role | Key boundary |
|---|---|---|---| |---|---|---|---|
| `relicario-core` | Rust | Crypto, item types, manifest, attachments, imgsecret, generators. Pure, no I/O. | Only `bytes-in / bytes-out`. No filesystem, no git, no network. | | `relicario-core` | Rust | Crypto, item types, manifest, attachments, imgsecret, generators, device keys / fingerprints. Pure, no I/O. | Only `bytes-in / bytes-out`. No filesystem, no git, no network. |
| `relicario-cli` | Rust binary | Wraps core with filesystem ops, git plumbing, clap UX. | Only entry point that runs without a browser; sole working interface during disaster recovery. | | `relicario-cli` | Rust binary | Wraps core with filesystem ops, git plumbing, clap UX. | Only entry point that runs without a browser; sole working interface during disaster recovery. |
| `relicario-wasm` | Rust → WASM | Thin `#[wasm_bindgen]` exports from core for the extension. | Compiles `relicario-core` to WASM; no extra logic. | | `relicario-wasm` | Rust → WASM | Thin `#[wasm_bindgen]` exports from core for the extension. | Compiles `relicario-core` to WASM; no extra logic. |
| `relicario-server` | Rust binary | Pre-receive Git hook (`verify-commit`) plus hook installer (`generate-hook`) running on the vault remote. Verifies SSH-signed commits against `.relicario/devices.json` and `.relicario/revoked.json`. | Lives on the git server, not on a client device. The only Relicario component the user does not run themselves. Sees only public key material. |
| `extension` | TypeScript | Browser-resident UI. Five entry-point bundles (popup, vault tab, setup, content script, service worker). | The service worker is the only crypto holder; popup/vault/content/setup never touch the master key. | | `extension` | TypeScript | Browser-resident UI. Five entry-point bundles (popup, vault tab, setup, content script, service worker). | The service worker is the only crypto holder; popup/vault/content/setup never touch the master key. |
The CLI and the extension are **at parity**: every user-facing capability lands in both surfaces together. Diverging is allowed only with a documented reason. See the per-codebase docs for which surface owns which user flow. The CLI and the extension are **at parity**: every user-facing capability lands in both surfaces together. Diverging is allowed only with a documented reason. See the per-codebase docs for which surface owns which user flow. The server has no user-facing surface — it is a server-side enforcer of the device-auth invariant the clients already agreed to.
## Inter-codebase contracts ## Inter-codebase contracts
@@ -151,6 +155,7 @@ The CLI keeps its master key in process memory; if the process exits or crashes,
| Target | Tool | Output | When to run | | Target | Tool | Output | When to run |
|---|---|---|---| |---|---|---|---|
| Native CLI | `cargo build` (debug or `--release`) | `target/{debug,release}/relicario` | After CLI changes; for distribution | | Native CLI | `cargo build` (debug or `--release`) | `target/{debug,release}/relicario` | After CLI changes; for distribution |
| Server hook | `cargo build -p relicario-server --release` | `target/release/relicario-server` | After server changes; deploy onto the git remote |
| Native test suites | `cargo test` (workspace) | — | After any Rust change | | Native test suites | `cargo test` (workspace) | — | After any Rust change |
| WASM module | `wasm-pack build --target web` (via `npm run build:wasm`) | `extension/wasm/relicario_wasm{,_bg.wasm,.js}` | After core or wasm crate changes | | WASM module | `wasm-pack build --target web` (via `npm run build:wasm`) | `extension/wasm/relicario_wasm{,_bg.wasm,.js}` | After core or wasm crate changes |
| Chrome extension | `webpack` (`npm run build`) | `extension/dist/` | After TS or WASM changes; for Chrome distribution | | Chrome extension | `webpack` (`npm run build`) | `extension/dist/` | After TS or WASM changes; for Chrome distribution |
@@ -196,6 +201,7 @@ Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take for
| A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](../../extension/ARCHITECTURE.md) | | A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](../../extension/ARCHITECTURE.md) |
| A new SW message type | `extension/src/shared/messages.ts` (capability sets), then [`extension/ARCHITECTURE.md § Invariants`](../../extension/ARCHITECTURE.md) | | A new SW message type | `extension/src/shared/messages.ts` (capability sets), then [`extension/ARCHITECTURE.md § Invariants`](../../extension/ARCHITECTURE.md) |
| A new GitHost (e.g. GitLab support) | `extension/src/service-worker/git-host.ts` (interface) and existing implementations | | A new GitHost (e.g. GitLab support) | `extension/src/service-worker/git-host.ts` (interface) and existing implementations |
| The pre-receive hook / device-auth enforcement | `crates/relicario-server/src/main.rs`, then `docs/superpowers/specs/2026-05-02-device-authentication-design.md` for rationale |
| Adding a new item type | core's `item_types/` mod, then CLI's `build_*_item`/`edit_*` helpers, then extension's `popup/components/types/<type>.ts` | | Adding a new item type | core's `item_types/` mod, then CLI's `build_*_item`/`edit_*` helpers, then extension's `popup/components/types/<type>.ts` |
| Threat model / why a primitive was chosen | `docs/superpowers/specs/2026-04-11-relicario-design.md` (historical, but authoritative for rationale) | | Threat model / why a primitive was chosen | `docs/superpowers/specs/2026-04-11-relicario-design.md` (historical, but authoritative for rationale) |
| Format of the import/export feature | `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` (designed but not yet implemented) | | Format of the import/export feature | `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` (designed but not yet implemented) |

View File

@@ -0,0 +1,165 @@
# Multi-Agent Development Paradigm
This repo uses a three-terminal workflow for large development lifts: one Claude Code session acts as **PM** and two act as **senior developers** (Dev-A, Dev-B), each working in their own git worktree on a parallel feature branch.
A local relay MCP server eliminates manual message copying between terminals — agents call `post_message`/`read_messages` instead of asking the user to copy-paste.
---
## Overview
| Role | Terminal | Branch | Responsibilities |
|------|----------|--------|-----------------|
| PM | 1 | `main` (read-only) | Drive doc-audit follow-ups, review PRs, write CHANGELOG, authorize merges and tagging |
| Dev-A | 2 | `feature/<release>-plan-a-*` | Implement Plan A tasks in their own worktree |
| Dev-B | 3 | `feature/<release>-plan-b-*` | Implement Plan B tasks in their own worktree |
| Relay server | 4 | — | Message bus; Ctrl-C to stop at end of lift |
**User's job:** authorize merges (the PM asks), resolve escalations the PM can't handle, and watch the streams. You are no longer the message bus.
---
## Starting a lift
### Prerequisites
- [ ] Kickoff prompts exist in `docs/superpowers/coordination/` (generate with the `multi-agent-kickoff` skill if not)
- [ ] No uncommitted changes in main that would confuse the devs
- [ ] `tools/relay/` is present (run `ls tools/relay/` to confirm)
### Launch sequence
```bash
# 1. Start the relay server (this terminal becomes the relay log)
tools/relay/start.sh # prints copy-paste instructions, then starts server
# Optional: use a multiplexer to auto-open all four terminals
tools/relay/start.sh --tmux # creates tmux session "relay-lift" with 4 windows
tools/relay/start.sh --kitty # creates kitty tab "relay" + 3 windows
```
`start.sh` prints the paths to the three kickoff prompt files. In each Claude Code terminal, run `cat <path>` and paste everything below the `---` line as the first message.
---
## Coordination protocol
Agents communicate by posting structured blocks to each other's inboxes. Four message kinds:
| Kind | Block header | When used |
|------|-------------|-----------|
| `status` | `## STATUS UPDATE — DEV-*` | After completing a task, getting blocked, or reaching a review-ready state |
| `question` | `## QUESTION TO PM — DEV-*` | When a dev needs PM input mid-task |
| `directive` | `## DIRECTIVE TO DEV-*` | When PM instructs a dev to proceed, hold, rescope, or approve a PR |
| `free` | (none) | Ad-hoc messages not covered by the above |
A well-formed `status` block:
```
## STATUS UPDATE — DEV-B
Time: 2026-05-02T14:30:00-07:00
Branch: feature/v0.5.0-plan-b-extension-ux
Task: P4 / error-copy map
Status: DONE
Last commit: abc1234 feat(extension): centralize ERROR_COPY map
Tests: green
Notes: No issues. Ready for PM review of P4 before starting B1.
```
---
## Using the relay tools
All three Claude Code sessions have these tools available when the relay server is running:
```
post_message(from, to, kind, body) → { id }
read_messages(for) → RelayMessage[] (drains inbox)
list_pending(for) → { count, kinds } (non-destructive)
```
Typical dev flow per task:
```
1. read_messages(for="dev-b") # check for directives before starting
2. ... do the work ...
3. post_message(from="dev-b", to="pm", kind="status", body="## STATUS UPDATE...")
```
Typical PM flow:
```
1. read_messages(for="pm") # see what devs posted
2. ... review ...
3. post_message(from="pm", to="dev-b", kind="directive", body="## DIRECTIVE TO DEV-B...")
```
---
## If the relay server isn't running
Claude Code will show a yellow MCP connection warning for the `relay` server. The tools will be unavailable.
Agents fall back to the manual protocol: they emit the structured blocks as text and ask the user to copy-paste them to the relevant terminal. This is slower but fully functional — the coordination protocol works either way.
To restart a crashed server mid-lift:
```bash
tools/relay/start.sh
```
In-flight messages are lost on restart. Any agent with unread messages should re-post them.
---
## Generating kickoff prompts
### Full workflow (spec → plans → kickoff)
**Step 1 — Write a spec**
Run the `superpowers:brainstorming` skill. At the end it invokes `superpowers:writing-plans` for each dev stream. Each stream gets its own plan file in `docs/superpowers/plans/`. The spec lives in `docs/superpowers/specs/`.
**Step 2 — Invoke the kickoff skill**
Say anything like:
- "kick off the multi-agent thing for v0.6.0"
- "spin up PM and devs for this release"
- "set up the three-terminal paradigm"
The `multi-agent-kickoff` skill auto-triggers on those phrases. It will:
1. Auto-discover the spec and plans by date/release label (asks to confirm if ambiguous)
2. Generate `docs/superpowers/coordination/<release>-pm-prompt.md` and one `-dev-<letter>-prompt.md` per plan
3. Inject the relay paragraph, branch names, worktree paths, test commands, and scope partitioning automatically from the plans and `CLAUDE.md`
4. Commit the prompts and print launch instructions
N>2 devs works automatically — 3 plans produces PM + Dev-A/B/C prompts.
**Step 3 — Launch**
```bash
tools/relay/start.sh # prints prompt file paths, starts relay server
# open N+1 terminals, paste each prompt below its '---' line
```
The skill reminder: run `tools/relay/start.sh` **before** opening the Claude Code sessions — the MCP tools need the server up when each session initializes.
---
## Ending a lift
1. PM emits `REVIEW-COMPLETE` and `MERGE-APPROVED` for each dev's PR
2. User merges each PR (the PM session does `gh pr merge` with user authorization)
3. PM tags the release (only after explicit user `yes`)
4. Ctrl-C the relay terminal — all in-memory messages are discarded
---
## Roles and boundaries (quick reference)
**PM must not:** write feature code, merge without user authorization, tag without user approval, run `git push --force` / `git reset --hard` without asking.
**Devs must not:** merge their branch to main, push `--force`, run `git reset --hard` without asking.
**User must:** authorize all merges and the release tag. Everything else is delegated.

View File

@@ -5,8 +5,9 @@ Pre-v0.5.0 audit of Relicario's documentation against the current codebase.
## Summary ## Summary
- **Total findings:** 14 - **Total findings:** 14
- **Fixed inline:** 6 - **Fixed inline (initial pass):** 6
- **Need user input (proposed only):** 8 - **Fixed during v0.5.0 PM run (this audit, follow-up commits):** 8
- **No action needed:** 0
- **Top 3 recommendations:** - **Top 3 recommendations:**
1. **Add `relicario-server` to architecture docs.** It exists in `crates/`, is referenced by SECURITY.md, and underpins device-auth, but `docs/architecture/overview.md`'s "three codebases" framing and `CLAUDE.md`'s project-structure tree still pretend it doesn't exist (Findings 1, 2, 9). This is the single biggest gap before tagging v0.5.0. 1. **Add `relicario-server` to architecture docs.** It exists in `crates/`, is referenced by SECURITY.md, and underpins device-auth, but `docs/architecture/overview.md`'s "three codebases" framing and `CLAUDE.md`'s project-structure tree still pretend it doesn't exist (Findings 1, 2, 9). This is the single biggest gap before tagging v0.5.0.
2. **Replace `CLAUDE.md`'s Roadmap.** It still says "Next: WASM build + Chrome MV3 browser extension (Plan 2)" — a milestone that shipped weeks ago. Multiple subsequent train rounds (typed items, attachments, backup, LastPass, device auth, fullscreen UX phases) have shipped, none of which are reflected (Finding 3). 2. **Replace `CLAUDE.md`'s Roadmap.** It still says "Next: WASM build + Chrome MV3 browser extension (Plan 2)" — a milestone that shipped weeks ago. Multiple subsequent train rounds (typed items, attachments, backup, LastPass, device auth, fullscreen UX phases) have shipped, none of which are reflected (Finding 3).
@@ -24,7 +25,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** The repo now has **four** Rust crates (`relicario-core`, `relicario-cli`, `relicario-wasm`, `relicario-server`) plus the extension. The framing "The three codebases" + accompanying ASCII diagram + four-row table all predate the May 2026 server crate. `relicario-server` is the pre-receive hook binary that enforces device-signature verification — load-bearing for the device-auth model that SECURITY.md already advertises. **Issue:** The repo now has **four** Rust crates (`relicario-core`, `relicario-cli`, `relicario-wasm`, `relicario-server`) plus the extension. The framing "The three codebases" + accompanying ASCII diagram + four-row table all predate the May 2026 server crate. `relicario-server` is the pre-receive hook binary that enforces device-signature verification — load-bearing for the device-auth model that SECURITY.md already advertises.
**Fix:** Re-title the section ("The four codebases" or "The relicario codebases"), add a server box to the diagram, add a row to the table. The role is "Pre-receive Git hook that verifies commit signatures against `.relicario/devices.json` and `.relicario/revoked.json`". **Fix:** Re-title the section ("The four codebases" or "The relicario codebases"), add a server box to the diagram, add a row to the table. The role is "Pre-receive Git hook that verifies commit signatures against `.relicario/devices.json` and `.relicario/revoked.json`".
**Severity:** must-fix-before-v0.5.0 **Severity:** must-fix-before-v0.5.0
**Status:** Proposed; needs user decision (>50 words of new prose; touches the framing of the whole overview doc) **Status:** Fixed in `ca059e7` (PM follow-up, 2026-05-02): "four codebases" framing, ASCII diagram fans core out to cli + server + wasm, table row added, build matrix gains `cargo build -p relicario-server`, "Where to look next" points at server src + design spec.
--- ---
@@ -34,7 +35,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** The `crates/` tree only lists `relicario-core/`, `relicario-cli/`, `relicario-wasm/`. `relicario-server/` is missing. Since CLAUDE.md is the project-level summary every Claude session reads, this is the highest-leverage staleness. **Issue:** The `crates/` tree only lists `relicario-core/`, `relicario-cli/`, `relicario-wasm/`. `relicario-server/` is missing. Since CLAUDE.md is the project-level summary every Claude session reads, this is the highest-leverage staleness.
**Fix:** Add a fourth crate entry for `relicario-server/` with `src/main.rs # pre-receive hook: verify_commit + generate_hook`. **Fix:** Add a fourth crate entry for `relicario-server/` with `src/main.rs # pre-receive hook: verify_commit + generate_hook`.
**Severity:** must-fix-before-v0.5.0 **Severity:** must-fix-before-v0.5.0
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled per audit constraints) **Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): added `relicario-server/` entry to project tree.
--- ---
@@ -44,7 +45,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** Says `Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM).` Plan 2 (extension) shipped, then Plans 1A-1C, 3A (backup), 3B (LastPass), Plan 4 (security fixes + device auth), and Phases 1-2B of the fullscreen UX redesign all shipped. The current "next thing" per project memory is v0.5.0 polish + harden plus Phase 3/4 of fullscreen UX. **Issue:** Says `Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM).` Plan 2 (extension) shipped, then Plans 1A-1C, 3A (backup), 3B (LastPass), Plan 4 (security fixes + device auth), and Phases 1-2B of the fullscreen UX redesign all shipped. The current "next thing" per project memory is v0.5.0 polish + harden plus Phase 3/4 of fullscreen UX.
**Fix:** Replace with a current-state Roadmap line (e.g. `Next: v0.5.0 polish + harden, then Phase 3 (vault tab shell). Mobile (ARM) and recovery QR remain on the roadmap.`). **Fix:** Replace with a current-state Roadmap line (e.g. `Next: v0.5.0 polish + harden, then Phase 3 (vault tab shell). Mobile (ARM) and recovery QR remain on the roadmap.`).
**Severity:** must-fix-before-v0.5.0 **Severity:** must-fix-before-v0.5.0
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled; phrasing is a judgment call) **Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): replaced with the v0.5.0 / Phases 3-4 / 1C-γ / LastPass / mobile / recovery-QR picture.
--- ---
@@ -54,7 +55,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** Audit M8 bumped `ItemId`/`FieldId` to 16-char hex (64 bits). Verified against `crates/relicario-core/src/ids.rs:3-4, 35-37` and `tests/integration` — they're 16 hex chars. The same line also doesn't mention that `AttachmentId` was bumped to 32 hex chars / 128 bits (audit I2/B4). **Issue:** Audit M8 bumped `ItemId`/`FieldId` to 16-char hex (64 bits). Verified against `crates/relicario-core/src/ids.rs:3-4, 35-37` and `tests/integration` — they're 16 hex chars. The same line also doesn't mention that `AttachmentId` was bumped to 32 hex chars / 128 bits (audit I2/B4).
**Fix:** Change to: `Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256(plaintext) (128 bits, audit I2/B4).` **Fix:** Change to: `Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256(plaintext) (128 bits, audit I2/B4).`
**Severity:** must-fix-before-v0.5.0 **Severity:** must-fix-before-v0.5.0
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled) **Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): now reads "Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256 over the plaintext (128 bits)."
--- ---
@@ -114,7 +115,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** The vault-creation pipeline in this doc shows `master_key → XChaCha20-Poly1305 → manifest.enc` only. In reality `cmd_init` also encrypts and writes `settings.enc` (default `VaultSettings`). Field-history-tracked items, attachments, the `Item` envelope shape — none of these are in the flow doc. Without context on typed items, a new contributor reading this doc would have a v0.1-era model of the system. **Issue:** The vault-creation pipeline in this doc shows `master_key → XChaCha20-Poly1305 → manifest.enc` only. In reality `cmd_init` also encrypts and writes `settings.enc` (default `VaultSettings`). Field-history-tracked items, attachments, the `Item` envelope shape — none of these are in the flow doc. Without context on typed items, a new contributor reading this doc would have a v0.1-era model of the system.
**Fix:** Add a settings.enc step to the flow; either expand the items section or note that the full item lifecycle is in `crates/relicario-core/ARCHITECTURE.md`. **Fix:** Add a settings.enc step to the flow; either expand the items section or note that the full item lifecycle is in `crates/relicario-core/ARCHITECTURE.md`.
**Severity:** nice-to-have (the per-codebase ARCHITECTURE.md files are the source of truth; this top-level doc could just point at them) **Severity:** nice-to-have (the per-codebase ARCHITECTURE.md files are the source of truth; this top-level doc could just point at them)
**Status:** Proposed; needs user decision (>50 words of new prose, design choice between rewriting vs trimming) **Status:** Fixed in `76d092d` (PM follow-up, 2026-05-02): trim path. Added settings.enc as a parallel artifact in the encrypt step, then a short paragraph pointing at `crates/relicario-core/ARCHITECTURE.md` for the per-item lifecycle.
--- ---
@@ -124,7 +125,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** Says `Device registration was optional before v0.4.0. With device auth enabled, all commits must be signed by a registered device.` But (a) v0.4.0 hasn't been tagged yet — the changelog goes v0.1.0 → v0.2.0 → "Unreleased", and the next tag-in-flight per project memory is v0.5.0; (b) per the v0.5.0 polish + harden spec, device-auth enforcement is **currently a no-op** because the pre-receive hook fix (S1) hasn't landed. Saying "all commits MUST be signed" is aspirational, not current. **Issue:** Says `Device registration was optional before v0.4.0. With device auth enabled, all commits must be signed by a registered device.` But (a) v0.4.0 hasn't been tagged yet — the changelog goes v0.1.0 → v0.2.0 → "Unreleased", and the next tag-in-flight per project memory is v0.5.0; (b) per the v0.5.0 polish + harden spec, device-auth enforcement is **currently a no-op** because the pre-receive hook fix (S1) hasn't landed. Saying "all commits MUST be signed" is aspirational, not current.
**Fix:** Reword to clarify (a) the actual version line (e.g. "Pre-v0.5.0 vaults can opt out by leaving `devices.json` empty"), AND (b) acknowledge that signature *enforcement* depends on the pre-receive hook being deployed and the S1 fix landing. Could just be a one-line caveat. **Fix:** Reword to clarify (a) the actual version line (e.g. "Pre-v0.5.0 vaults can opt out by leaving `devices.json` empty"), AND (b) acknowledge that signature *enforcement* depends on the pre-receive hook being deployed and the S1 fix landing. Could just be a one-line caveat.
**Severity:** must-fix-before-v0.5.0 (security-doc accuracy is part of the legibility pitch) **Severity:** must-fix-before-v0.5.0 (security-doc accuracy is part of the legibility pitch)
**Status:** Proposed; needs user decision (security wording — exact phrasing matters) **Status:** Fixed in `1342228` (PM follow-up, 2026-05-02 with user approval): dropped the "before v0.4.0" version line entirely (v0.4.0 was never tagged); replaced with a single line saying registration is optional but recommended for shared vaults. Enforcement story now lives in the Device Authentication section (see F12).
--- ---
@@ -134,7 +135,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** The "Device Authentication" section refers to a "pre-receive hook" but never says it lives in `crates/relicario-server`, what binary the hook calls (`relicario-server verify-commit <sha>`), or how to install it (`relicario-server generate-hook`). For a self-hosted user reading this to decide whether to enable it, those are the two essential operational facts. **Issue:** The "Device Authentication" section refers to a "pre-receive hook" but never says it lives in `crates/relicario-server`, what binary the hook calls (`relicario-server verify-commit <sha>`), or how to install it (`relicario-server generate-hook`). For a self-hosted user reading this to decide whether to enable it, those are the two essential operational facts.
**Fix:** Add a short paragraph naming the crate and the two subcommands, pointing to the design spec. **Fix:** Add a short paragraph naming the crate and the two subcommands, pointing to the design spec.
**Severity:** nice-to-have **Severity:** nice-to-have
**Status:** Proposed; needs user decision (>50 words of new prose) **Status:** Fixed in `1342228` (PM follow-up, 2026-05-02): added paragraph naming the `relicario-server` crate, both subcommands (`generate-hook`, `verify-commit`), and the caveat that signed commits without the server hook provide authorship metadata only.
--- ---
@@ -144,7 +145,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
**Issue:** This doc is explicitly historical (per `docs/architecture/overview.md` "Stale spec docs" disclaimer), so editing it as architecture would violate convention. Still worth flagging that "Post-V1 Ideas" lists secure notes, secure documents, mobile, LastPass import, Firefox extension, TOTP — most of which have shipped. Per project policy this is *informational only*; the spec is a time-stamped decision artifact. **Issue:** This doc is explicitly historical (per `docs/architecture/overview.md` "Stale spec docs" disclaimer), so editing it as architecture would violate convention. Still worth flagging that "Post-V1 Ideas" lists secure notes, secure documents, mobile, LastPass import, Firefox extension, TOTP — most of which have shipped. Per project policy this is *informational only*; the spec is a time-stamped decision artifact.
**Fix:** None — leave alone. If desired, prepend a one-line "Status: V1 shipped 2026-04-22; many Post-V1 ideas have since landed — see CHANGELOG.md" at the top of the file. **Fix:** None — leave alone. If desired, prepend a one-line "Status: V1 shipped 2026-04-22; many Post-V1 ideas have since landed — see CHANGELOG.md" at the top of the file.
**Severity:** informational **Severity:** informational
**Status:** Proposed; needs user decision (touches a historical spec the user may want to leave frozen) **Status:** Fixed in `9c97f9f` (PM follow-up, 2026-05-02): added the optional one-line status banner at the top of the spec pointing at CHANGELOG.md and overview.md for current state. Body of the spec untouched per the "specs are frozen decision artifacts" convention.
--- ---
@@ -160,10 +161,18 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
## Inline-fix verification ## Inline-fix verification
Files modified during this audit: ### Initial pass (commit `900ccf1`):
- `README.md` — vault layout (`items/`, `settings.enc`, `attachments/`), crate tree (added `relicario-wasm`, `relicario-server`, typed-items modules), ID width, Roadmap. - `README.md` — vault layout (`items/`, `settings.enc`, `attachments/`), crate tree (added `relicario-wasm`, `relicario-server`, typed-items modules), ID width, Roadmap.
- `docs/ARCHITECTURE.md` — git-server box (`items/`, `settings.enc`, `attachments/`, `revoked.json`), crate-architecture inner box (current core modules), removed "Future: relicario-wasm" line. - `docs/ARCHITECTURE.md` — git-server box (`items/`, `settings.enc`, `attachments/`, `revoked.json`), crate-architecture inner box (current core modules), removed "Future: relicario-wasm" line.
- `docs/architecture/overview.md` — conventions table (16-char hex IDs, 128-bit AttachmentIds). - `docs/architecture/overview.md` — conventions table (16-char hex IDs, 128-bit AttachmentIds).
No source files, `Cargo.lock`, or extension code were modified. CLAUDE.md, SECURITY.md, and the foundational design spec were not modified — those changes need user review per the audit constraints. ### v0.5.0 PM follow-up pass (commits `ca059e7`, `8fd9a05`, `1342228`, `76d092d`, `9c97f9f`):
- `docs/architecture/overview.md` — F1: four-codebases framing, ASCII diagram fans out to server, table row, build matrix, "Where to look next".
- `CLAUDE.md` — F2: project tree gains `relicario-server`. F3: Roadmap line replaced. F4: Item/Field/Attachment ID widths and entropy noted.
- `docs/SECURITY.md` — F11: dropped `before v0.4.0` line. F12: Device Authentication section now names the `relicario-server` crate and its subcommands, with the "without the hook, commits are advisory" caveat.
- `docs/ARCHITECTURE.md` — F10: settings.enc shown alongside manifest.enc in the Vault Creation Flow; pointer added to per-crate ARCHITECTURE.md for typed-items detail.
- `docs/superpowers/specs/2026-04-11-relicario-design.md` — F13: optional one-line "historical spec" status banner at top.
No source files, `Cargo.lock`, or extension code were modified at any point.

View File

@@ -0,0 +1,956 @@
# Relay Server Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a local MCP SSE server that gives PM, Dev-A, and Dev-B Claude Code sessions native `post_message`/`read_messages`/`list_pending` tools, eliminating manual copy-paste during multi-agent development lifts.
**Architecture:** A single Node.js process hosts an HTTP server with SSE transport for the MCP protocol. Three named in-memory FIFO queues (one per role) hold consume-once messages. A `start.sh` launcher prints copy-paste instructions (default) or spawns a tmux/kitty layout (flags). The multi-agent-kickoff skill templates get a `<<RELAY_PARAGRAPH>>` placeholder injected so every future lift prompt auto-includes relay instructions.
**Tech Stack:** Node.js v25, `@modelcontextprotocol/sdk` (MCP + SSE transport), `tsx` (dev dep, runs TypeScript directly), Node built-in `node:test` runner. No Express, no Hono, no Zod as a direct dep.
---
## File map
| Action | Path | Responsibility |
|--------|------|----------------|
| Create | `tools/relay/package.json` | npm metadata, scripts, single runtime dep + tsx devDep |
| Create | `tools/relay/tsconfig.json` | TypeScript config for ESM Node target |
| Create | `tools/relay/queue.ts` | `RelayQueue` class — in-memory FIFO, `post`/`read`/`pending`, `isRole` guard |
| Create | `tools/relay/queue.test.ts` | Node `node:test` unit tests for queue (5 cases) |
| Create | `tools/relay/server.ts` | MCP `Server` + `SSEServerTransport` HTTP server on port 7331 |
| Create | `tools/relay/start.sh` | Launcher: `--manual` (default), `--tmux`, `--kitty` |
| Modify | `.gitignore` | Add `tools/relay/node_modules/` |
| Modify | `.claude/settings.json` | Add `mcpServers.relay` SSE entry |
| Create | `docs/superpowers/MULTI-AGENT.md` | Paradigm reference README |
| Modify | `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md` | Add `<<RELAY_PARAGRAPH>>` section |
| Modify | `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md` | Add `<<RELAY_PARAGRAPH>>` section |
| Modify | `~/.claude/skills/multi-agent-kickoff/SKILL.md` | Placeholder ref + step 8 update + `<<DEV_ROLE>>` placeholder |
---
## Task 1: Scaffold `tools/relay/`
**Files:**
- Create: `tools/relay/package.json`
- Create: `tools/relay/tsconfig.json`
- Modify: `.gitignore`
- [ ] **Step 1: Create `tools/relay/package.json`**
```json
{
"name": "@relicario/relay",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "npx tsx server.ts",
"test": "node --import=tsx/esm --test queue.test.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.0"
},
"devDependencies": {
"tsx": "^4.19.0",
"@types/node": "^22.0.0"
}
}
```
- [ ] **Step 2: Create `tools/relay/tsconfig.json`**
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true
},
"include": ["*.ts"]
}
```
- [ ] **Step 3: Add to root `.gitignore`**
Open `/home/alee/Sources/relicario/.gitignore` and append:
```
tools/relay/node_modules/
```
- [ ] **Step 4: Install dependencies and verify**
```bash
cd tools/relay && npm install
```
Expected: `node_modules/` created, no errors. Verify with:
```bash
ls node_modules/@modelcontextprotocol/sdk && ls node_modules/tsx
```
Expected: both directories exist.
- [ ] **Step 5: Commit scaffold**
```bash
git add tools/relay/package.json tools/relay/tsconfig.json tools/relay/package-lock.json .gitignore
git commit -m "chore(relay): scaffold tools/relay with MCP SDK dep"
```
---
## Task 2: `queue.ts` — TDD
**Files:**
- Create: `tools/relay/queue.ts`
- Create: `tools/relay/queue.test.ts`
- [ ] **Step 1: Write the failing tests**
Create `tools/relay/queue.test.ts`:
```typescript
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { RelayQueue, isRole } from "./queue.ts";
describe("RelayQueue", () => {
let q: RelayQueue;
beforeEach(() => {
q = new RelayQueue();
});
it("post + read roundtrip returns the message with correct fields", () => {
q.post("dev-b", "pm", "status", "Task P4 DONE");
const msgs = q.read("pm");
assert.equal(msgs.length, 1);
assert.equal(msgs[0].from, "dev-b");
assert.equal(msgs[0].to, "pm");
assert.equal(msgs[0].kind, "status");
assert.equal(msgs[0].body, "Task P4 DONE");
assert.ok(typeof msgs[0].id === "string" && msgs[0].id.length > 0);
assert.ok(typeof msgs[0].ts === "string");
});
it("consume-once: second read returns empty", () => {
q.post("dev-a", "pm", "question", "Should I use approach A?");
q.read("pm");
const second = q.read("pm");
assert.deepEqual(second, []);
});
it("list_pending does not drain inbox", () => {
q.post("dev-b", "pm", "directive", "PROCEED");
const before = q.pending("pm");
assert.equal(before.count, 1);
const after = q.read("pm");
assert.equal(after.length, 1);
});
it("FIFO ordering across multiple senders", () => {
q.post("dev-a", "pm", "status", "first");
q.post("dev-b", "pm", "status", "second");
q.post("dev-a", "pm", "question", "third");
const msgs = q.read("pm");
assert.equal(msgs.length, 3);
assert.equal(msgs[0].body, "first");
assert.equal(msgs[1].body, "second");
assert.equal(msgs[2].body, "third");
});
it("isRole rejects unknown strings", () => {
assert.ok(isRole("pm"));
assert.ok(isRole("dev-a"));
assert.ok(isRole("dev-b"));
assert.ok(!isRole("dev-c"));
assert.ok(!isRole(""));
assert.ok(!isRole("PM"));
});
});
```
- [ ] **Step 2: Run tests to confirm they fail**
```bash
cd tools/relay && node --import=tsx/esm --test queue.test.ts
```
Expected: fails with `Cannot find module './queue.ts'` or similar. If it fails with a different error, investigate before continuing.
- [ ] **Step 3: Write `queue.ts`**
Create `tools/relay/queue.ts`:
```typescript
import { randomUUID } from "node:crypto";
export type Role = "pm" | "dev-a" | "dev-b";
export type MessageKind = "status" | "question" | "directive" | "free";
export interface RelayMessage {
id: string;
from: Role;
to: Role;
kind: MessageKind;
body: string;
ts: string;
}
const KNOWN_ROLES = new Set<string>(["pm", "dev-a", "dev-b"]);
export function isRole(s: string): s is Role {
return KNOWN_ROLES.has(s);
}
export class RelayQueue {
private readonly queues = new Map<Role, RelayMessage[]>([
["pm", []],
["dev-a", []],
["dev-b", []],
]);
post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage {
const msg: RelayMessage = {
id: randomUUID(),
from,
to,
kind,
body,
ts: new Date().toISOString(),
};
this.queues.get(to)!.push(msg);
return msg;
}
read(forRole: Role): RelayMessage[] {
const inbox = this.queues.get(forRole)!;
const messages = [...inbox];
inbox.length = 0;
return messages;
}
pending(forRole: Role): { count: number; kinds: MessageKind[] } {
const inbox = this.queues.get(forRole)!;
return {
count: inbox.length,
kinds: inbox.map((m) => m.kind),
};
}
}
```
- [ ] **Step 4: Run tests to confirm they pass**
```bash
cd tools/relay && node --import=tsx/esm --test queue.test.ts
```
Expected output (all 5 passing):
```
▶ RelayQueue
✔ post + read roundtrip returns the message with correct fields
✔ consume-once: second read returns empty
✔ list_pending does not drain inbox
✔ FIFO ordering across multiple senders
✔ isRole rejects unknown strings
▶ RelayQueue (Xms)
tests 5
pass 5
fail 0
```
If any test fails, fix `queue.ts` before proceeding.
- [ ] **Step 5: Commit**
```bash
git add tools/relay/queue.ts tools/relay/queue.test.ts
git commit -m "feat(relay): in-memory queue with consume-once semantics"
```
---
## Task 3: `server.ts`
**Files:**
- Create: `tools/relay/server.ts`
- [ ] **Step 1: Write `server.ts`**
Create `tools/relay/server.ts`:
```typescript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import http from "node:http";
import { RelayQueue, isRole } from "./queue.ts";
const PORT = 7331;
const queue = new RelayQueue();
const mcpServer = new Server(
{ name: "relay", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
const TOOLS = [
{
name: "post_message",
description:
"Push a message to a recipient's inbox. Returns the assigned message id.",
inputSchema: {
type: "object" as const,
properties: {
from: {
type: "string",
enum: ["pm", "dev-a", "dev-b"],
description: "Your role name",
},
to: {
type: "string",
enum: ["pm", "dev-a", "dev-b"],
description: "Recipient role name",
},
kind: {
type: "string",
enum: ["status", "question", "directive", "free"],
description: "Message type matching the coordination protocol",
},
body: {
type: "string",
description: "Message body — freeform markdown, typically the full formatted block",
},
},
required: ["from", "to", "kind", "body"],
},
},
{
name: "read_messages",
description:
"Pop and return all pending messages for this recipient. Inbox is empty after this call (consume-once).",
inputSchema: {
type: "object" as const,
properties: {
for: {
type: "string",
enum: ["pm", "dev-a", "dev-b"],
description: "Your role name",
},
},
required: ["for"],
},
},
{
name: "list_pending",
description:
"Return count and kinds of pending messages without consuming them.",
inputSchema: {
type: "object" as const,
properties: {
for: {
type: "string",
enum: ["pm", "dev-a", "dev-b"],
description: "Your role name",
},
},
required: ["for"],
},
},
];
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const a = args as Record<string, string>;
if (name === "post_message") {
if (!isRole(a.from)) {
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.from}"` }], isError: true };
}
if (!isRole(a.to)) {
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.to}"` }], isError: true };
}
const kind = a.kind as "status" | "question" | "directive" | "free";
const msg = queue.post(a.from, a.to, kind, a.body);
const ts = new Date(msg.ts).toTimeString().slice(0, 8);
const preview = a.body.slice(0, 60).replace(/\n/g, " ");
const ellipsis = a.body.length > 60 ? "..." : "";
process.stdout.write(`[${ts}] ${a.from}${a.to} [${kind}] "${preview}${ellipsis}"\n`);
return { content: [{ type: "text" as const, text: JSON.stringify({ id: msg.id }) }] };
}
if (name === "read_messages") {
if (!isRole(a.for)) {
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
}
const messages = queue.read(a.for);
return { content: [{ type: "text" as const, text: JSON.stringify(messages) }] };
}
if (name === "list_pending") {
if (!isRole(a.for)) {
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
}
const result = queue.pending(a.for);
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
}
return {
content: [{ type: "text" as const, text: `Error: unknown tool "${name}"` }],
isError: true,
};
});
const transports = new Map<string, SSEServerTransport>();
const httpServer = http.createServer(async (req, res) => {
try {
if (req.method === "GET" && req.url === "/sse") {
const transport = new SSEServerTransport("/message", res);
transports.set(transport.sessionId, transport);
transport.onclose = () => transports.delete(transport.sessionId);
await mcpServer.connect(transport);
} else if (req.method === "POST" && req.url?.startsWith("/message")) {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
const sessionId = url.searchParams.get("sessionId") ?? "";
const transport = transports.get(sessionId);
if (transport) {
await transport.handlePostMessage(req, res);
} else {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "session not found" }));
}
} else {
res.writeHead(404).end("not found");
}
} catch (err) {
console.error("[relay] error:", err);
if (!res.headersSent) res.writeHead(500).end(String(err));
}
});
httpServer.listen(PORT, "127.0.0.1", () => {
console.log(`[relay] server ready on :${PORT}`);
console.log(`[relay] tools: post_message, read_messages, list_pending`);
console.log(`[relay] waiting for connections — Ctrl-C to stop`);
});
```
- [ ] **Step 2: Smoke-test server startup**
In one terminal:
```bash
cd tools/relay && npx tsx server.ts
```
Expected output:
```
[relay] server ready on :7331
[relay] tools: post_message, read_messages, list_pending
[relay] waiting for connections — Ctrl-C to stop
```
In a second terminal, verify the port is listening:
```bash
curl -s --max-time 2 http://127.0.0.1:7331/sse | head -3
```
Expected: SSE `data:` stream begins (it won't complete — the connection stays open). Ctrl-C both.
If the server errors on startup, check that `@modelcontextprotocol/sdk` is installed and review any TypeScript errors by running `npx tsc --noEmit` in `tools/relay/`.
- [ ] **Step 3: Commit**
```bash
git add tools/relay/server.ts
git commit -m "feat(relay): MCP SSE server with post_message/read_messages/list_pending"
```
---
## Task 4: `start.sh`
**Files:**
- Create: `tools/relay/start.sh`
- [ ] **Step 1: Write `start.sh`**
Create `tools/relay/start.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
PORT=7331
MODE="manual"
for arg in "$@"; do
case "$arg" in
--tmux) MODE="tmux" ;;
--kitty) MODE="kitty" ;;
--manual) MODE="manual" ;;
*) echo "Unknown option: $arg" >&2; echo "Usage: $0 [--manual|--tmux|--kitty]" >&2; exit 1 ;;
esac
done
# Port check
if lsof -ti:"$PORT" &>/dev/null; then
echo "Error: port $PORT is already in use."
echo "Relay already running? Kill it with: kill \$(lsof -ti:$PORT)"
exit 1
fi
# Install deps (no-op if node_modules current)
cd "$SCRIPT_DIR"
npm install --silent
# Discover latest coordination prompts for instructions
COORD_DIR="$REPO_ROOT/docs/superpowers/coordination"
PM_PROMPT="$(ls -t "$COORD_DIR"/*-pm-prompt.md 2>/dev/null | head -1 || echo "(none found — run multi-agent-kickoff skill first)")"
DEV_A_PROMPT="$(ls -t "$COORD_DIR"/*-dev-a-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
DEV_B_PROMPT="$(ls -t "$COORD_DIR"/*-dev-b-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
print_manual_instructions() {
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ RELAY SERVER — MULTI-AGENT LIFT LAUNCHER ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
echo "Open 3 new terminals. In each, start Claude Code and paste"
echo "the content BELOW the '---' line from the corresponding file."
echo ""
echo " Terminal 1 (PM): cat '$PM_PROMPT'"
echo " Terminal 2 (Dev A): cat '$DEV_A_PROMPT'"
echo " Terminal 3 (Dev B): cat '$DEV_B_PROMPT'"
echo ""
echo "This terminal becomes the relay log. Keep it open."
echo ""
echo "══════════════════════════════════════════════════════════════"
}
launch_tmux() {
SESSION="relay-lift"
tmux new-session -d -s "$SESSION" -n "relay" \
"cd '$SCRIPT_DIR' && npx tsx server.ts"
tmux new-window -t "$SESSION:" -n "pm" "cd '$REPO_ROOT' && claude"
tmux new-window -t "$SESSION:" -n "dev-a" "cd '$REPO_ROOT' && claude"
tmux new-window -t "$SESSION:" -n "dev-b" "cd '$REPO_ROOT' && claude"
echo ""
echo "[relay] Opened tmux session '$SESSION' with 4 windows: relay, pm, dev-a, dev-b."
echo "[relay] Paste the kickoff prompt into each Claude window."
echo " Prompts:"
echo " PM: $PM_PROMPT"
echo " Dev A: $DEV_A_PROMPT"
echo " Dev B: $DEV_B_PROMPT"
echo ""
tmux attach-session -t "$SESSION"
}
launch_kitty() {
kitty @ launch --new-tab --tab-title "relay" -- \
bash -c "cd '$SCRIPT_DIR' && npx tsx server.ts"
kitty @ launch --new-window --window-title "PM" -- \
bash -c "cd '$REPO_ROOT' && claude"
kitty @ launch --new-window --window-title "Dev-A" -- \
bash -c "cd '$REPO_ROOT' && claude"
kitty @ launch --new-window --window-title "Dev-B" -- \
bash -c "cd '$REPO_ROOT' && claude"
echo ""
echo "[relay] Opened kitty tab 'relay' + 3 windows (PM, Dev-A, Dev-B)."
echo " Paste the kickoff prompts into each Claude window."
echo " PM: $PM_PROMPT"
echo " Dev A: $DEV_A_PROMPT"
echo " Dev B: $DEV_B_PROMPT"
}
case "$MODE" in
manual)
print_manual_instructions
exec npx tsx "$SCRIPT_DIR/server.ts"
;;
tmux)
launch_tmux
;;
kitty)
launch_kitty
;;
esac
```
- [ ] **Step 2: Make executable**
```bash
chmod +x tools/relay/start.sh
```
- [ ] **Step 3: Smoke-test `--manual` mode**
```bash
cd /home/alee/Sources/relicario && tools/relay/start.sh
```
Expected: prints the launch box with prompt paths, then server starts and shows `[relay] server ready on :7331`. Ctrl-C to stop.
- [ ] **Step 4: Commit**
```bash
git add tools/relay/start.sh
git commit -m "feat(relay): start.sh launcher with --manual/--tmux/--kitty modes"
```
---
## Task 5: Claude Code MCP configuration
**Files:**
- Modify: `.claude/settings.json`
- [ ] **Step 1: Read current `.claude/settings.json`**
```bash
cat .claude/settings.json
```
- [ ] **Step 2: Add the relay MCP server entry**
The file currently has `{ "enabledPlugins": { ... } }`. Add `"mcpServers"` at the top level:
```json
{
"mcpServers": {
"relay": {
"type": "sse",
"url": "http://localhost:7331/sse"
}
},
"enabledPlugins": {
"superpowers@claude-plugins-official": true
}
}
```
Preserve whatever is already in `enabledPlugins` — only add the `mcpServers` key.
- [ ] **Step 3: Commit**
```bash
git add .claude/settings.json
git commit -m "chore(relay): add relay MCP server to project Claude config"
```
---
## Task 6: `docs/superpowers/MULTI-AGENT.md`
**Files:**
- Create: `docs/superpowers/MULTI-AGENT.md`
- [ ] **Step 1: Write the paradigm README**
Create `docs/superpowers/MULTI-AGENT.md`:
```markdown
# Multi-Agent Development Paradigm
This repo uses a three-terminal workflow for large development lifts: one Claude Code session acts as **PM** and two act as **senior developers** (Dev-A, Dev-B), each working in their own git worktree on a parallel feature branch.
A local relay MCP server eliminates manual message copying between terminals — agents call `post_message`/`read_messages` instead of asking the user to copy-paste.
---
## Overview
| Role | Terminal | Branch | Responsibilities |
|------|----------|--------|-----------------|
| PM | 1 | `main` (read-only) | Drive doc-audit follow-ups, review PRs, write CHANGELOG, authorize merges and tagging |
| Dev-A | 2 | `feature/<release>-plan-a-*` | Implement Plan A tasks in their own worktree |
| Dev-B | 3 | `feature/<release>-plan-b-*` | Implement Plan B tasks in their own worktree |
| Relay server | 4 | — | Message bus; Ctrl-C to stop at end of lift |
**User's job:** authorize merges (the PM asks), resolve escalations the PM can't handle, and watch the streams. You are no longer the message bus.
---
## Starting a lift
### Prerequisites
- [ ] Kickoff prompts exist in `docs/superpowers/coordination/` (generate with the `multi-agent-kickoff` skill if not)
- [ ] No uncommitted changes in main that would confuse the devs
- [ ] `tools/relay/` is present (run `ls tools/relay/` to confirm)
### Launch sequence
```bash
# 1. Start the relay server (this terminal becomes the relay log)
tools/relay/start.sh # prints copy-paste instructions, then starts server
# Optional: use a multiplexer to auto-open all four terminals
tools/relay/start.sh --tmux # creates tmux session "relay-lift" with 4 windows
tools/relay/start.sh --kitty # creates kitty tab "relay" + 3 windows
```
`start.sh` prints the paths to the three kickoff prompt files. In each Claude Code terminal, run `cat <path>` and paste everything below the `---` line as the first message.
---
## Coordination protocol
Agents communicate by posting structured blocks to each other's inboxes. Four message kinds:
| Kind | Block header | When used |
|------|-------------|-----------|
| `status` | `## STATUS UPDATE — DEV-*` | After completing a task, getting blocked, or reaching a review-ready state |
| `question` | `## QUESTION TO PM — DEV-*` | When a dev needs PM input mid-task |
| `directive` | `## DIRECTIVE TO DEV-*` | When PM instructs a dev to proceed, hold, rescope, or approve a PR |
| `free` | (none) | Ad-hoc messages not covered by the above |
A well-formed `status` block:
```
## STATUS UPDATE — DEV-B
Time: 2026-05-02T14:30:00-07:00
Branch: feature/v0.5.0-plan-b-extension-ux
Task: P4 / error-copy map
Status: DONE
Last commit: abc1234 feat(extension): centralize ERROR_COPY map
Tests: green
Notes: No issues. Ready for PM review of P4 before starting B1.
```
---
## Using the relay tools
All three Claude Code sessions have these tools available when the relay server is running:
```
post_message(from, to, kind, body) → { id }
read_messages(for) → RelayMessage[] (drains inbox)
list_pending(for) → { count, kinds } (non-destructive)
```
Typical dev flow per task:
```
1. read_messages(for="dev-b") # check for directives before starting
2. ... do the work ...
3. post_message(from="dev-b", to="pm", kind="status", body="## STATUS UPDATE...")
```
Typical PM flow:
```
1. read_messages(for="pm") # see what devs posted
2. ... review ...
3. post_message(from="pm", to="dev-b", kind="directive", body="## DIRECTIVE TO DEV-B...")
```
---
## If the relay server isn't running
Claude Code will show a yellow MCP connection warning for the `relay` server. The tools will be unavailable.
Agents fall back to the manual protocol: they emit the structured blocks as text and ask the user to copy-paste them to the relevant terminal. This is slower but fully functional — the coordination protocol works either way.
To restart a crashed server mid-lift:
```bash
tools/relay/start.sh
```
In-flight messages are lost on restart. Any agent with unread messages should re-post them.
---
## Generating kickoff prompts
Use the `multi-agent-kickoff` skill (in the `superpowers` plugin). It auto-discovers the spec and plans for the release, substitutes all placeholders including the relay paragraph, and writes files to `docs/superpowers/coordination/`.
The skill reminder: run `tools/relay/start.sh` **before** opening the three Claude Code sessions — the MCP tools need the server to be up when each session initializes.
---
## Ending a lift
1. PM emits `REVIEW-COMPLETE` and `MERGE-APPROVED` for each dev's PR
2. User merges each PR (the PM session does `gh pr merge` with user authorization)
3. PM tags the release (only after explicit user `yes`)
4. Ctrl-C the relay terminal — all in-memory messages are discarded
---
## Roles and boundaries (quick reference)
**PM must not:** write feature code, merge without user authorization, tag without user approval, run `git push --force` / `git reset --hard` without asking.
**Devs must not:** merge their branch to main, push `--force`, run `git reset --hard` without asking.
**User must:** authorize all merges and the release tag. Everything else is delegated.
```
- [ ] **Step 2: Commit**
```bash
git add docs/superpowers/MULTI-AGENT.md
git commit -m "docs: add multi-agent development paradigm README"
```
---
## Task 7: Update `multi-agent-kickoff` skill
**Files:**
- Modify: `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md`
- Modify: `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md`
- Modify: `~/.claude/skills/multi-agent-kickoff/SKILL.md`
- [ ] **Step 1: Read current templates**
```bash
cat ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md
cat ~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
```
Note where the "Setup" section ends in each template. The relay paragraph goes right after it (before "Required reading").
- [ ] **Step 2: Add `<<RELAY_PARAGRAPH>>` to `pm-prompt.md`**
In `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md`, find the "## Setup" section and add the placeholder block immediately after it (before the "## Required reading" heading):
```markdown
<<RELAY_PARAGRAPH>>
```
The generated output for this placeholder (substituted by the skill at generation time) is:
```markdown
## Relay server
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
- `list_pending(for)` — check inbox count without consuming
Recipients: `pm`, `dev-a`, `dev-b`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-a", kind="directive", body="...")`.
```
- [ ] **Step 3: Add `<<RELAY_PARAGRAPH>>` to `dev-prompt.md`**
In `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md`, find the "## Setup" section and add immediately after it (before "## Required reading"):
```markdown
<<RELAY_PARAGRAPH>>
```
The generated output for this placeholder is role-specific (uses `<<DEV_ROLE>>`):
```markdown
## Relay server
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"<<DEV_ROLE>>"`
- `read_messages(for)` — drain your inbox; call with `for="<<DEV_ROLE>>"` before each task
- `list_pending(for)` — check inbox count without consuming
Recipients: `pm`, `dev-a`, `dev-b`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="<<DEV_ROLE>>")`. After emitting any status/question block: `post_message(from="<<DEV_ROLE>>", to="pm", kind="status"|"question", body="...")`.
```
- [ ] **Step 4: Update `SKILL.md` — add two entries to the placeholder reference table**
In `~/.claude/skills/multi-agent-kickoff/SKILL.md`, find the "### Common to all prompts" section of the Placeholder reference and add:
```markdown
- `<<RELAY_PARAGRAPH>>` — the relay server instruction block (substituted from the template above). For the PM prompt, `from` is hardcoded to `"pm"`. For dev prompts, uses `<<DEV_ROLE>>`.
```
In the "### Per-dev" section, add:
```markdown
- `<<DEV_ROLE>>` — lowercase relay role name, e.g. `dev-a`, `dev-b`. Derived from `<<DEV_LETTER>>` by lowercasing and prepending `dev-`. Set when `<<DEV_LETTER>>` is set.
```
- [ ] **Step 5: Update `SKILL.md` — step 8 (kickoff instructions)**
Find step 8 in the Process section ("Print kickoff instructions") and prepend a bullet:
```markdown
8. **Print kickoff instructions.** Tell the user exactly what to do:
- **Start the relay server first:** `tools/relay/start.sh` (or `--tmux`/`--kitty` for auto-layout). The server must be running before the sessions open so the MCP tools initialize correctly.
- Open three terminal windows (or panes — their choice of multiplexer)
...rest of existing bullets unchanged...
```
Also update the "After generation" section — change bullet 4 ("From that point on, they're the message bus...") to:
```markdown
4. The relay server handles message routing — agents call `post_message`/`read_messages` directly. The user only needs to step in for escalations the PM can't resolve, or if the relay server is down (manual fallback: copy-paste the block to the relevant terminal as before)
```
- [ ] **Step 6: Commit skill changes**
The skill files live outside the git repo, so no git commit needed. Verify the changes look right:
```bash
grep -n "RELAY_PARAGRAPH\|DEV_ROLE\|relay server" ~/.claude/skills/multi-agent-kickoff/SKILL.md | head -10
grep -n "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md
grep -n "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
```
Expected: each grep returns at least one match.
---
## Final verification
- [ ] **Run queue tests one more time from repo root**
```bash
cd tools/relay && node --import=tsx/esm --test queue.test.ts
```
Expected: 5 passing, 0 failing.
- [ ] **Start server and verify it binds**
```bash
tools/relay/start.sh &
sleep 1
curl -s --max-time 1 http://127.0.0.1:7331/sse | head -1 || true
kill %1
```
Expected: `data:` line appears (SSE stream started), then server killed cleanly.
- [ ] **Verify MCP config is present**
```bash
python3 -c "import json; d=json.load(open('.claude/settings.json')); print(d['mcpServers']['relay'])"
```
Expected: `{'type': 'sse', 'url': 'http://localhost:7331/sse'}`
- [ ] **Verify skill placeholders were added**
```bash
grep "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md \
~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
```
Expected: one match per file.

View File

@@ -1,5 +1,7 @@
# Relicario — Design Specification # Relicario — Design Specification
> **Status:** historical. V1 shipped 2026-04-22; several "Post-V1 Ideas" listed below (typed items, attachments, secure documents, TOTP, Firefox extension, LastPass import, device authentication) have since shipped. See `CHANGELOG.md` and `docs/architecture/overview.md` for current state. Do not edit this spec as if it were architecture documentation — it is a time-stamped decision artifact.
A git-backed, self-hostable password manager with a Rust core, CLI, and Chrome browser extension. The reference image as a DCT-embedded secret carrier is the core differentiator. A git-backed, self-hostable password manager with a Rust core, CLI, and Chrome browser extension. The reference image as a DCT-embedded secret carrier is the core differentiator.
## Overview ## Overview

View File

@@ -0,0 +1,218 @@
# Relay Server Design
**Date:** 2026-05-02
**Status:** Approved
**Scope:** Dev tooling — not shipped in any product artifact
---
## Problem
Multi-agent development lifts (PM + Dev-A + Dev-B in parallel Claude Code sessions) require passing status updates, questions, and directives between terminals. Today the user manually copies and pastes every message block. This is error-prone, breaks flow, and scales poorly as lift complexity grows.
## Goal
A lightweight MCP server running on localhost that gives all three Claude Code sessions native tools to post and read messages. The user stops being the message bus.
---
## Repository layout
```
tools/relay/
├── package.json # private, not published; single dep: @modelcontextprotocol/sdk
├── tsconfig.json
├── server.ts # MCP SSE server entry point (~150 lines)
├── queue.ts # in-memory queue logic (~50 lines)
├── queue.test.ts # Node built-in test runner
└── start.sh # launcher script
```
Added to root `.gitignore`: `tools/relay/node_modules/`, `tools/relay/dist/`.
---
## Tech stack
- **Runtime:** Node.js (v25, already installed at `/usr/bin/node`)
- **Package manager:** npm (bun has known compat gaps with the MCP SDK's SSE transport)
- **Dependencies:** `@modelcontextprotocol/sdk`, `tsx` (devDependency, runs TypeScript directly — no compile step)
- **Transport:** SSE (`SSEServerTransport` from the SDK handles the HTTP layer — no Express or Hono needed)
- **Port:** `7331` (hardcoded; easy to remember, unlikely to collide)
---
## Queue model
Three named inboxes: `pm`, `dev-a`, `dev-b`. Each is a FIFO array in memory.
Message shape:
```ts
interface RelayMessage {
id: string; // uuid v4
from: string; // sender role name
to: string; // recipient role name
kind: "status" | "question" | "directive" | "free";
body: string; // freeform, typically the existing markdown block format
ts: string; // ISO 8601
}
```
`kind` maps to the existing coordination protocol:
| kind | existing block |
|-------------|-----------------------------|
| `status` | `## STATUS UPDATE — DEV-*` |
| `question` | `## QUESTION TO PM — DEV-*` |
| `directive` | `## DIRECTIVE TO DEV-*` |
| `free` | ad-hoc / unstructured |
Messages are **consume-once**: `read_messages` drains the inbox. There is no persistence — if the server restarts mid-lift, in-flight messages are lost. Acceptable for a dev tool; agents re-send on reconnect.
---
## MCP tool surface
All three tools are exposed to every connected session.
### `post_message`
```
post_message(from: "pm"|"dev-a"|"dev-b", to: "pm"|"dev-a"|"dev-b", kind: "status"|"question"|"directive"|"free", body: string) → { id: string }
```
Pushes one message onto the target's inbox. Returns the assigned message id. Errors if `to` or `from` is not a known role. Agents declare their own identity via `from` — the kickoff prompt tells each agent its role name.
### `read_messages`
```
read_messages(for: "pm"|"dev-a"|"dev-b") → RelayMessage[]
```
Pops and returns all pending messages for that recipient, in FIFO order. After this call the inbox is empty.
### `list_pending`
```
list_pending(for: "pm"|"dev-a"|"dev-b") → { count: number, kinds: string[] }
```
Returns count and kind breakdown of pending messages without consuming them. Lets an agent cheaply check "do I have anything to act on?" before committing to a `read_messages` call.
---
## Server terminal output
Every `post_message` call prints a one-liner to stdout in the dedicated relay terminal:
```
[14:32:01] dev-b → pm [status] "Task P4 DONE, last commit abc1234..."
[14:33:15] pm → dev-b [directive] "PROCEED to task B1"
```
This log is the operational value of keeping the server in a dedicated terminal rather than backgrounding it.
---
## Launcher script (`start.sh`)
`start.sh` accepts one optional flag:
| Flag | Behavior |
|------------|----------|
| *(default)*| `--manual` mode: prints three labeled prompt blocks (one per role) for copy-paste into fresh Claude Code sessions, then starts the server in the foreground |
| `--tmux` | Creates a new tmux window with four panes: relay server + PM + Dev-A + Dev-B, each pre-loaded with its kickoff command |
| `--kitty` | Same layout using kitty's `launch --new-tab` / `--new-window` |
Execution order (all modes):
1. `cd tools/relay && npm install --silent` (no-op if `node_modules` is current)
2. Print the session snippet (copy-paste blocks or multiplexer launch)
3. Foreground `npx tsx server.ts` — the terminal that ran `start.sh` becomes the relay terminal; no compile step needed
Port-already-in-use check before step 3: if `:7331` is bound, print `relay already running? kill it with: kill $(lsof -ti:7331)` and exit 1.
---
## Claude Code configuration
Add to project `.claude/settings.json`:
```json
"mcpServers": {
"relay": {
"type": "sse",
"url": "http://localhost:7331/sse"
}
}
```
This is project-scoped — the relay tools only appear in Relicario Claude Code sessions. When the server is not running, Claude Code shows a yellow MCP connection warning but does not break. Agents gracefully fall back to asking the user to relay manually (existing behavior).
---
## Kickoff prompt changes
One paragraph added near the top of each coordination prompt (`v0.5.0-pm-prompt.md`, `v0.5.0-dev-a-prompt.md`, `v0.5.0-dev-b-prompt.md` as template):
> **Relay server:** A message-bus MCP server is running. You have three tools: `post_message(to, kind, body)`, `read_messages(for)`, `list_pending(for)`. Recipients: `pm`, `dev-a`, `dev-b`. Use these instead of asking the user to copy-paste. Before starting each task call `read_messages(for="<your-role>")`. After emitting any status, question, or directive block, call `post_message` with `kind` set to the block type and `body` set to the formatted block.
The `multi-agent-kickoff` skill is updated to:
- Remind the user to run `tools/relay/start.sh` before opening the three sessions
- Inject the relay paragraph automatically into every generated kickoff prompt
---
## Error handling
| Scenario | Behavior |
|----------|----------|
| Unknown `to` in `post_message` | MCP error returned; message not queued |
| Server crash / restart | In-flight messages lost; agent re-sends |
| Port 7331 in use at startup | Startup exits 1 with a kill hint |
| Session connects before server starts | Claude Code shows MCP warning; agent falls back to manual relay |
No authentication. This is localhost-only, single-machine, dev-tool use.
---
## Testing
`queue.test.ts` using Node's built-in `node:test` runner. No extra test dep.
Coverage:
- `post_message` + `read_messages` roundtrip (single and multiple messages)
- Consume-once: second `read_messages` on same inbox returns empty
- `list_pending` does not drain inbox
- FIFO ordering across multiple senders to the same inbox
- Unknown recipient returns an error
No integration test against the MCP SSE transport — that is the SDK's responsibility.
---
## Top-level README (`docs/superpowers/MULTI-AGENT.md`)
A durable reference document covering the whole development paradigm — not the relay server specifically, but the entire three-terminal workflow that the relay server enables. Lives in `docs/superpowers/` alongside the specs and plans it describes.
**Contents:**
1. **Overview** — the PM/Dev-A/Dev-B pattern: why three terminals, what each role owns, what the user's job is (authorize merges, resolve escalations)
2. **Starting a lift** — prerequisites checklist, then: `tools/relay/start.sh` → three sessions → paste kickoff prompts
3. **Coordination protocol reference** — the four message kinds (`status`, `question`, `directive`, `free`), when each is used, what a well-formed body looks like
4. **Using the relay tools**`post_message`, `read_messages`, `list_pending` with one-liner examples
5. **If the relay server isn't running** — fallback to manual copy-paste; the coordination protocol still works, just with the user as bus
6. **Generating kickoff prompts** — point to the `multi-agent-kickoff` skill; note that the skill injects the relay paragraph automatically
7. **Ending a lift** — PM emits MERGE-APPROVED, devs push branches, user authorizes merges, Ctrl-C the relay terminal
This README is written for future-you opening the repo six months from now, not for the current lift.
---
## What this is not
- Not a product feature — never bundled with the extension or CLI
- Not persistent — no SQLite, no file queue, in-memory only
- Not authenticated — localhost dev tool, no threat model
- Not a general-purpose message bus — three hardcoded roles, no dynamic registration

View File

@@ -0,0 +1,13 @@
// Stub for the runtime-only WASM module. Used by vitest so that modules
// importing relicario_wasm.js can be loaded in a Node/happy-dom environment.
// Individual tests that exercise WASM calls should mock the relevant exports.
export default async function init(): Promise<void> {}
export const unlock = (): never => { throw new Error('wasm stub: unlock not mocked'); };
export const lock = (): void => {};
export const manifest_encrypt = (): never => { throw new Error('wasm stub: manifest_encrypt not mocked'); };
export const manifest_decrypt = (): never => { throw new Error('wasm stub: manifest_decrypt not mocked'); };
export const settings_encrypt = (): never => { throw new Error('wasm stub: settings_encrypt not mocked'); };
export const default_vault_settings_json = (): string => '{}';
export const embed_image_secret = (): never => { throw new Error('wasm stub: embed_image_secret not mocked'); };
export const register_device = (): never => { throw new Error('wasm stub: register_device not mocked'); };

View File

@@ -12,6 +12,22 @@ vi.mock('../../../shared/state', () => ({
})); }));
import { sendMessage } from '../../../shared/state'; import { sendMessage } from '../../../shared/state';
import { DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR } from '../../../shared/color-scheme';
function mockChromeStorage(initial: Record<string, unknown> = {}) {
const store: Record<string, unknown> = { ...initial };
(global as any).chrome = {
storage: {
sync: {
get: vi.fn((key: string) => Promise.resolve(
key in store ? { [key]: store[key] } : {})),
set: vi.fn((kv: Record<string, unknown>) => { Object.assign(store, kv); return Promise.resolve(); }),
remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }),
},
},
};
return store;
}
function settingsResponses() { function settingsResponses() {
// Two parallel calls in renderSettings: get_settings + get_blacklist. // Two parallel calls in renderSettings: get_settings + get_blacklist.
@@ -30,6 +46,7 @@ describe('settings view', () => {
}); });
it('renders a Sync now button', async () => { it('renders a Sync now button', async () => {
mockChromeStorage();
settingsResponses(); settingsResponses();
await renderSettings(app); await renderSettings(app);
@@ -38,6 +55,7 @@ describe('settings view', () => {
}); });
it('clicking Sync now sends a sync message and shows feedback on success', async () => { it('clicking Sync now sends a sync message and shows feedback on success', async () => {
mockChromeStorage();
settingsResponses(); settingsResponses();
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true }); (sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
@@ -52,6 +70,7 @@ describe('settings view', () => {
}); });
it('shows the error when sync fails', async () => { it('shows the error when sync fails', async () => {
mockChromeStorage();
settingsResponses(); settingsResponses();
(sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' }); (sendMessage as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: false, error: 'remote_unreachable' });
@@ -64,3 +83,109 @@ describe('settings view', () => {
expect(status.textContent).toMatch(/remote_unreachable/); expect(status.textContent).toMatch(/remote_unreachable/);
}); });
}); });
describe('settings Display section', () => {
let app: HTMLElement;
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
app = document.getElementById('app')!;
(sendMessage as ReturnType<typeof vi.fn>).mockReset();
});
it('renders digit and symbol color pickers with default values when storage is empty', async () => {
mockChromeStorage();
settingsResponses();
await renderSettings(app);
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color');
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color');
expect(digitInput).not.toBeNull();
expect(symbolInput).not.toBeNull();
expect(digitInput!.value).toBe(DEFAULT_DIGIT_COLOR);
expect(symbolInput!.value).toBe(DEFAULT_SYMBOL_COLOR);
});
it('renders pickers with stored values when storage has a scheme', async () => {
mockChromeStorage({
password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' },
});
settingsResponses();
await renderSettings(app);
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color');
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color');
expect(digitInput!.value).toBe('#112233');
expect(symbolInput!.value).toBe('#aabbcc');
});
it('renders a color-preview-swatch element', async () => {
mockChromeStorage();
settingsResponses();
await renderSettings(app);
expect(app.querySelector('#display-swatch')).not.toBeNull();
});
it('changing digit color calls saveColorScheme with updated scheme', async () => {
mockChromeStorage();
settingsResponses();
await renderSettings(app);
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color')!;
digitInput.value = '#ff0000';
digitInput.dispatchEvent(new Event('change'));
await new Promise((r) => setTimeout(r, 0));
const syncSet = (global as any).chrome.storage.sync.set as ReturnType<typeof vi.fn>;
expect(syncSet).toHaveBeenCalledWith(
expect.objectContaining({
password_display_scheme: expect.objectContaining({ digit_color: '#ff0000' }),
}),
);
});
it('changing symbol color calls saveColorScheme with updated scheme', async () => {
mockChromeStorage();
settingsResponses();
await renderSettings(app);
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color')!;
symbolInput.value = '#00ff00';
symbolInput.dispatchEvent(new Event('change'));
await new Promise((r) => setTimeout(r, 0));
const syncSet = (global as any).chrome.storage.sync.set as ReturnType<typeof vi.fn>;
expect(syncSet).toHaveBeenCalledWith(
expect.objectContaining({
password_display_scheme: expect.objectContaining({ symbol_color: '#00ff00' }),
}),
);
});
it('clicking reset calls chrome.storage.sync.remove and restores defaults', async () => {
mockChromeStorage({
password_display_scheme: { digit_color: '#112233', symbol_color: '#aabbcc' },
});
settingsResponses();
await renderSettings(app);
const resetBtn = app.querySelector<HTMLButtonElement>('#display-reset')!;
resetBtn.click();
await new Promise((r) => setTimeout(r, 0));
const syncRemove = (global as any).chrome.storage.sync.remove as ReturnType<typeof vi.fn>;
expect(syncRemove).toHaveBeenCalledWith('password_display_scheme');
const digitInput = app.querySelector<HTMLInputElement>('#display-digit-color')!;
const symbolInput = app.querySelector<HTMLInputElement>('#display-symbol-color')!;
expect(digitInput.value).toBe(DEFAULT_DIGIT_COLOR);
expect(symbolInput.value).toBe(DEFAULT_SYMBOL_COLOR);
});
});

View File

@@ -1,6 +1,7 @@
/// Field history view — shows password/concealed field history for an item. /// Field history view — shows password/concealed field history for an item.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state'; import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state';
import { colorizePassword } from '../../shared/password-coloring';
import type { FieldHistoryView } from '../../shared/types'; import type { FieldHistoryView } from '../../shared/types';
function relativeTime(unixSec: number): string { function relativeTime(unixSec: number): string {
@@ -103,6 +104,16 @@ export async function renderFieldHistory(app: HTMLElement): Promise<void> {
</div> </div>
`; `;
// Colorize revealed entries: replace plain-text content with colorized spans
app.querySelectorAll<HTMLElement>('.history-entry__value.revealed').forEach((el) => {
const key = el.closest<HTMLElement>('.history-entry')?.dataset.entry ?? '';
const plaintext = valueStore.get(key);
if (plaintext !== undefined) {
el.textContent = '';
el.appendChild(colorizePassword(plaintext));
}
});
// Wire handlers // Wire handlers
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail')); app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail'));

View File

@@ -6,6 +6,7 @@
/// copy click handlers on any rendered rows. /// copy click handlers on any rendered rows.
import { escapeHtml } from '../../shared/state'; import { escapeHtml } from '../../shared/state';
import { colorizePassword } from '../../shared/password-coloring';
import type { Item, Section, Field, FieldValue } from '../../shared/types'; import type { Item, Section, Field, FieldValue } from '../../shared/types';
export interface RowOpts { export interface RowOpts {
@@ -46,6 +47,7 @@ export interface ConcealedRowOpts {
id: string; id: string;
label: string; label: string;
value: string; value: string;
kind?: 'password' | 'concealed';
monospace?: boolean; monospace?: boolean;
multiline?: boolean; multiline?: boolean;
} }
@@ -53,12 +55,15 @@ export interface ConcealedRowOpts {
/// Concealed row — value rendered hidden until the user clicks "show". /// Concealed row — value rendered hidden until the user clicks "show".
/// Plaintext is stored in `data-field-value` on the row element and copied /// Plaintext is stored in `data-field-value` on the row element and copied
/// to the visible value span on reveal. Copy button always copies plaintext. /// to the visible value span on reveal. Copy button always copies plaintext.
/// When `kind` is "password", wireFieldHandlers applies colorizePassword on
/// reveal so digits/symbols/letters are rendered in distinct colours.
export function renderConcealedRow(opts: ConcealedRowOpts): string { export function renderConcealedRow(opts: ConcealedRowOpts): string {
const { id, label, value, monospace, multiline } = opts; const { id, label, value, kind, monospace, multiline } = opts;
const placeholder = multiline ? `•••• (${value.length} chars)` : '••••'; const placeholder = multiline ? `•••• (${value.length} chars)` : '••••';
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`; const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
const kindAttr = kind ? ` data-field-kind="${escapeHtml(kind)}"` : '';
return ` return `
<div class="field-row" data-field-id="${escapeHtml(id)}" data-revealed="false" data-field-value="${escapeHtml(value)}" data-field-multiline="${multiline ? 'true' : 'false'}"> <div class="field-row" data-field-id="${escapeHtml(id)}" data-revealed="false" data-field-value="${escapeHtml(value)}" data-field-multiline="${multiline ? 'true' : 'false'}"${kindAttr}>
<span class="field-row__label">${escapeHtml(label)}</span> <span class="field-row__label">${escapeHtml(label)}</span>
<span class="${valueClass}" data-field-role="value">${escapeHtml(placeholder)}</span> <span class="${valueClass}" data-field-role="value">${escapeHtml(placeholder)}</span>
<span class="field-row__actions"> <span class="field-row__actions">
@@ -100,8 +105,14 @@ export function wireFieldHandlers(scope: HTMLElement): void {
valueEl.textContent = placeholder; valueEl.textContent = placeholder;
row.setAttribute('data-revealed', 'false'); row.setAttribute('data-revealed', 'false');
btn.textContent = 'show'; btn.textContent = 'show';
} else {
const isPassword = row.getAttribute('data-field-kind') === 'password';
valueEl.textContent = '';
if (isPassword) {
valueEl.appendChild(colorizePassword(plaintext));
} else { } else {
valueEl.textContent = plaintext; valueEl.textContent = plaintext;
}
row.setAttribute('data-revealed', 'true'); row.setAttribute('data-revealed', 'true');
btn.textContent = 'hide'; btn.textContent = 'hide';
} }
@@ -150,6 +161,7 @@ export function renderSections(item: Item, idPrefix: string): string {
id: `${idPrefix}-s${sIdx}-f${fIdx}`, id: `${idPrefix}-s${sIdx}-f${fIdx}`,
label: field.label, label: field.label,
value: field.value.value, value: field.value.value,
kind: field.value.kind,
}); });
} }
}); });

View File

@@ -6,6 +6,7 @@
import { sendMessage } from '../../shared/state'; import { sendMessage } from '../../shared/state';
import type { GeneratorRequest, VaultSettings } from '../../shared/types'; import type { GeneratorRequest, VaultSettings } from '../../shared/types';
import { colorizePassword } from '../../shared/password-coloring';
interface UiKnobs { interface UiKnobs {
kind: 'random' | 'bip39'; kind: 'random' | 'bip39';
@@ -138,7 +139,10 @@ export function openGeneratorPanel(opts: OpenPanelOpts): void {
const d = resp.data as { password?: string; passphrase?: string }; const d = resp.data as { password?: string; passphrase?: string };
currentPreview = d.password ?? d.passphrase ?? ''; currentPreview = d.password ?? d.passphrase ?? '';
const el = host.querySelector('.preview__value'); const el = host.querySelector('.preview__value');
if (el) el.textContent = currentPreview; if (el) {
el.textContent = '';
el.appendChild(colorizePassword(currentPreview));
}
updateValidation(); updateValidation();
} }
}, 150); }, 150);

View File

@@ -3,6 +3,11 @@
import { sendMessage, navigate, escapeHtml } from '../../shared/state'; import { sendMessage, navigate, escapeHtml } from '../../shared/state';
import type { DeviceSettings } from '../../shared/types'; import type { DeviceSettings } from '../../shared/types';
import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs'; import { GLYPH_TRASH, GLYPH_DEVICES } from '../../shared/glyphs';
import {
loadColorScheme, saveColorScheme, resetColorScheme,
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
} from '../../shared/color-scheme';
import { colorizePassword } from '../../shared/password-coloring';
export async function renderSettings(app: HTMLElement): Promise<void> { export async function renderSettings(app: HTMLElement): Promise<void> {
app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>'; app.innerHTML = '<div class="pad" style="text-align:center; padding-top:20px;"><span class="spinner"></span></div>';
@@ -62,6 +67,9 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
<div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div> <div id="sync-status" class="muted" style="font-size:12px;min-height:16px;"></div>
</div> </div>
<div style="margin-bottom:16px;" id="display-section-container">
</div>
<div> <div>
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div> <div style="font-size:12px; color:#8b949e; margin-bottom:6px;">blacklisted sites</div>
<div id="blacklist-container"> <div id="blacklist-container">
@@ -119,4 +127,65 @@ export async function renderSettings(app: HTMLElement): Promise<void> {
} }
}); });
}); });
// Render Display section after the rest of the DOM is ready
await renderDisplaySection();
}
function updateSwatch(swatch: HTMLElement, digitColor: string, symbolColor: string): void {
swatch.style.setProperty('--relicario-pwd-digit-color', digitColor);
swatch.style.setProperty('--relicario-pwd-symbol-color', symbolColor);
swatch.innerHTML = '';
swatch.appendChild(colorizePassword('Abc123!@#xyz'));
}
async function renderDisplaySection(): Promise<void> {
// The Display section container must be present in the DOM before we call this
const container = document.getElementById('display-section-container');
if (!container) return;
const scheme = await loadColorScheme();
container.innerHTML = `
<div style="font-size:12px; color:#8b949e; margin-bottom:6px;">display</div>
<div style="margin-bottom:8px;">
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
<input type="color" id="display-digit-color" value="${escapeHtml(scheme.digit_color)}">
digit color
</label>
</div>
<div style="margin-bottom:8px;">
<label style="display:flex; align-items:center; gap:8px; font-size:13px;">
<input type="color" id="display-symbol-color" value="${escapeHtml(scheme.symbol_color)}">
symbol color
</label>
</div>
<div id="display-swatch" class="color-preview-swatch"></div>
<div style="margin-top:8px;">
<button id="display-reset" class="btn" style="font-size:11px;">reset to defaults</button>
</div>
`;
const digitInput = document.getElementById('display-digit-color') as HTMLInputElement;
const symbolInput = document.getElementById('display-symbol-color') as HTMLInputElement;
const swatch = document.getElementById('display-swatch') as HTMLElement;
// Render initial swatch
updateSwatch(swatch, scheme.digit_color, scheme.symbol_color);
async function onColorChange(): Promise<void> {
const newScheme = { digit_color: digitInput.value, symbol_color: symbolInput.value };
await saveColorScheme(newScheme);
updateSwatch(swatch, newScheme.digit_color, newScheme.symbol_color);
}
digitInput.addEventListener('change', () => void onColorChange());
symbolInput.addEventListener('change', () => void onColorChange());
document.getElementById('display-reset')?.addEventListener('click', async () => {
await resetColorScheme();
digitInput.value = DEFAULT_DIGIT_COLOR;
symbolInput.value = DEFAULT_SYMBOL_COLOR;
updateSwatch(swatch, DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR);
});
} }

View File

@@ -26,7 +26,7 @@ vi.mock('../../../../setup/setup-helpers', () => ({
entropyText: vi.fn(() => ''), entropyText: vi.fn(() => ''),
})); }));
import { renderForm } from '../login'; import { renderForm, applyGeneratedPassword } from '../login';
import { sendMessage } from '../../../../shared/state'; import { sendMessage } from '../../../../shared/state';
describe('login form smart inputs', () => { describe('login form smart inputs', () => {
@@ -154,3 +154,37 @@ describe('Login save shape', () => {
expect(addCall).toBeUndefined(); expect(addCall).toBeUndefined();
}); });
}); });
describe('regenerate handler dispatches input event', () => {
it('dispatches an InputEvent on the input after value is set', () => {
const input = document.createElement('input');
input.type = 'password';
document.body.appendChild(input);
const dispatchSpy = vi.spyOn(input, 'dispatchEvent');
applyGeneratedPassword(input, 'sCMtTJkF%GN^mF#-N6D%');
expect(input.value).toBe('sCMtTJkF%GN^mF#-N6D%');
expect(input.type).toBe('text');
expect(dispatchSpy).toHaveBeenCalled();
const evt = dispatchSpy.mock.calls.find(c => c[0] instanceof InputEvent)?.[0] as InputEvent;
expect(evt).toBeDefined();
expect(evt.type).toBe('input');
expect(evt.bubbles).toBe(true);
document.body.removeChild(input);
});
it('bubbling listener fires when applyGeneratedPassword is called', () => {
const input = document.createElement('input');
document.body.appendChild(input);
let listenerFired = false;
input.addEventListener('input', () => { listenerFired = true; });
applyGeneratedPassword(input, 'newpass');
expect(listenerFired).toBe(true);
document.body.removeChild(input);
});
});

View File

@@ -29,6 +29,15 @@ import { wireTotpPreview, wireTotpQr } from '../../../shared/form-affordances/to
import { wireNotesMonoToggle } from '../../../shared/form-affordances/notes-tools'; import { wireNotesMonoToggle } from '../../../shared/form-affordances/notes-tools';
import { scheduleRate } from '../../../setup/setup-helpers'; import { scheduleRate } from '../../../setup/setup-helpers';
/// Sets a generated password on an input, reveals it as plain text, then
/// dispatches a synthetic InputEvent so listeners (e.g. the strength meter)
/// re-evaluate the new value.
export function applyGeneratedPassword(input: HTMLInputElement, value: string): void {
input.value = value;
input.type = 'text';
input.dispatchEvent(new InputEvent('input', { bubbles: true }));
}
/// Called by the dispatcher before each render. Stops any in-flight /// Called by the dispatcher before each render. Stops any in-flight
/// tickers / intervals / listeners the previous view may have attached. /// tickers / intervals / listeners the previous view may have attached.
export function teardown(): void { export function teardown(): void {
@@ -75,7 +84,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise<void>
${renderSignatureBlock({ accent: 'gold', children: sigInner })} ${renderSignatureBlock({ accent: 'gold', children: sigInner })}
</div> </div>
${username ? renderRow({ label: 'username', value: username, copyable: true }) : ''} ${username ? renderRow({ label: 'username', value: username, copyable: true }) : ''}
${renderConcealedRow({ id: 'login-password', label: 'password', value: password })} ${renderConcealedRow({ id: 'login-password', label: 'password', value: password, kind: 'password' })}
${url ? renderRow({ label: 'url', value: url, href: url }) : ''} ${url ? renderRow({ label: 'url', value: url, href: url }) : ''}
${hasTotp ? ` ${hasTotp ? `
<div class="field-row"> <div class="field-row">
@@ -348,6 +357,7 @@ export function renderForm(
${sectionsHtml} ${sectionsHtml}
<div class="${surface === 'fullscreen' ? 'form-lower' : ''}">
<div class="form-group"> <div class="form-group">
<div class="notes-with-toggle"> <div class="notes-with-toggle">
<label class="label" for="f-notes" style="margin:0;flex:1;">notes</label> <label class="label" for="f-notes" style="margin:0;flex:1;">notes</label>
@@ -363,6 +373,7 @@ export function renderForm(
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button> <button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
</div> </div>
</div> </div>
</div>
`; `;
const rerender = (): void => { const rerender = (): void => {
@@ -433,7 +444,7 @@ export function renderForm(
context: 'fill-field', context: 'fill-field',
onPicked: (value) => { onPicked: (value) => {
const pw = document.getElementById('f-password') as HTMLInputElement | null; const pw = document.getElementById('f-password') as HTMLInputElement | null;
if (pw) { pw.value = value; pw.type = 'text'; } if (pw) applyGeneratedPassword(pw, value);
}, },
}); });
}); });

View File

@@ -4,6 +4,7 @@
/// Navigation works by updating `currentState` and calling `render()`. /// Navigation works by updating `currentState` and calling `render()`.
import type { Request, Response } from '../shared/messages'; import type { Request, Response } from '../shared/messages';
import { lookupErrorCopy } from '../shared/error-copy';
import type { ItemId, ManifestEntry, Item } from '../shared/types'; import type { ItemId, ManifestEntry, Item } from '../shared/types';
import { registerHost } from '../shared/state'; import { registerHost } from '../shared/state';
import { renderUnlock } from './components/unlock'; import { renderUnlock } from './components/unlock';
@@ -18,6 +19,7 @@ import { renderFieldHistory } from './components/field-history';
import { teardown as teardownTrash } from './components/trash'; import { teardown as teardownTrash } from './components/trash';
import { teardown as teardownDevices } from './components/devices'; import { teardown as teardownDevices } from './components/devices';
import { teardown as teardownFieldHistory } from './components/field-history'; import { teardown as teardownFieldHistory } from './components/field-history';
import { applyColorScheme } from '../shared/color-scheme';
// --- Escape HTML to prevent XSS --- // --- Escape HTML to prevent XSS ---
export function escapeHtml(str: string): string { export function escapeHtml(str: string): string {
@@ -144,19 +146,8 @@ export function humanizeError(err: string): string {
if (/settings json:/i.test(err)) { if (/settings json:/i.test(err)) {
return 'Settings are in an invalid format — try reloading the extension.'; return 'Settings are in an invalid format — try reloading the extension.';
} }
if (/vault_locked/i.test(err)) { const copy = lookupErrorCopy(err);
return 'Vault is locked. Unlock and try again.'; return copy.body;
}
if (/origin_mismatch/i.test(err)) {
return 'This login belongs to a different site — refusing to leak credentials cross-origin.';
}
if (/unauthorized_sender/i.test(err)) {
return 'This action is not allowed from here.';
}
if (/tab_navigated|captured_tab_gone/i.test(err)) {
return 'The browser tab changed before the fill could complete — try again.';
}
return err;
} }
// --- Navigation --- // --- Navigation ---
@@ -225,6 +216,14 @@ function render(): void {
// --- Init --- // --- Init ---
async function init(): Promise<void> { async function init(): Promise<void> {
await applyColorScheme();
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'sync' && 'password_display_scheme' in changes) {
void applyColorScheme();
}
});
// Snapshot the active tab at popup-open — the fill path uses this // Snapshot the active tab at popup-open — the fill path uses this
// tabId/url pair so the SW can verify the tab hasn't navigated before // tabId/url pair so the SW can verify the tab hasn't navigated before
// forwarding credentials (audit M5 + TOCTOU close via expectedHost). // forwarding credentials (audit M5 + TOCTOU close via expectedHost).

View File

@@ -38,6 +38,10 @@
/* Focus */ /* Focus */
--focus-ring: 0 0 0 2px var(--gold-ring); --focus-ring: 0 0 0 2px var(--gold-ring);
/* Password coloring (P1) */
--relicario-pwd-digit-color: #2563eb;
--relicario-pwd-symbol-color: #dc2626;
} }
* { * {
@@ -1554,3 +1558,18 @@ textarea {
.logo-lockup .brand-logo { width: 42px; height: 42px; margin: 0 auto 10px; } .logo-lockup .brand-logo { width: 42px; height: 42px; margin: 0 auto 10px; }
.logo-lockup .brand { font-size: 17px; font-weight: 600; color: var(--gold-text); letter-spacing: 0.5px; } .logo-lockup .brand { font-size: 17px; font-weight: 600; color: var(--gold-text); letter-spacing: 0.5px; }
.tagline { color: var(--text-dim); font-size: 11px; margin-top: 4px; letter-spacing: 0.3px; } .tagline { color: var(--text-dim); font-size: 11px; margin-top: 4px; letter-spacing: 0.3px; }
/* Password character-class coloring */
.pwd-digit { color: var(--relicario-pwd-digit-color); }
.pwd-symbol { color: var(--relicario-pwd-symbol-color); }
.pwd-letter { color: inherit; }
.color-preview-swatch {
font-family: ui-monospace, monospace;
font-size: 1.1rem;
padding: 8px 12px;
border: 1px solid var(--border-mid);
border-radius: 4px;
margin-top: 8px;
background: var(--bg-input);
}

View File

@@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { finishSetup } from '../setup';
describe('finishSetup', () => {
beforeEach(() => {
(global as any).chrome = {
tabs: {
create: vi.fn(() => Promise.resolve({ id: 999 })),
getCurrent: vi.fn(() => Promise.resolve({ id: 42 })),
remove: vi.fn(() => Promise.resolve()),
},
runtime: {
getURL: vi.fn((p: string) => `chrome-extension://abc/${p}`),
},
};
});
it('opens vault.html in a new tab', async () => {
await finishSetup();
expect(chrome.runtime.getURL).toHaveBeenCalledWith('vault.html');
expect(chrome.tabs.create).toHaveBeenCalledWith({
url: 'chrome-extension://abc/vault.html',
});
});
it('closes the current setup tab after opening the vault tab', async () => {
await finishSetup();
expect(chrome.tabs.getCurrent).toHaveBeenCalled();
expect(chrome.tabs.remove).toHaveBeenCalledWith(42);
});
it('still opens the vault tab even if closing the setup tab fails', async () => {
(chrome.tabs.remove as any).mockRejectedValueOnce(new Error('no permission'));
await expect(finishSetup()).resolves.not.toThrow();
expect(chrome.tabs.create).toHaveBeenCalled();
});
});

View File

@@ -1101,6 +1101,7 @@ function attachStep5(): void {
state.configPushed = true; state.configPushed = true;
render(); render();
void finishSetup();
} catch (err: unknown) { } catch (err: unknown) {
console.error('[relicario setup] register device failed:', err); console.error('[relicario setup] register device failed:', err);
state.error = `Failed to register device: ${err instanceof Error ? err.message : String(err)}`; state.error = `Failed to register device: ${err instanceof Error ? err.message : String(err)}`;
@@ -1131,6 +1132,23 @@ function attachStep5(): void {
}); });
} }
// --- Completion handoff ---
/// Open the fullscreen vault tab and best-effort close the setup tab.
export async function finishSetup(): Promise<void> {
const vaultUrl = chrome.runtime.getURL('vault.html');
await chrome.tabs.create({ url: vaultUrl });
try {
const current = await chrome.tabs.getCurrent();
if (current?.id !== undefined) {
await chrome.tabs.remove(current.id);
}
} catch {
// Setup tab may not be closeable (e.g., opened as popup rather than a tab).
// The vault tab is open — that's the user-visible success.
}
}
// --- Boot --- // --- Boot ---
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
loadColorScheme, saveColorScheme, resetColorScheme, applyColorScheme,
DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR,
} from '../color-scheme';
function mockChromeStorage(initial: any = {}) {
const store = { ...initial };
(global as any).chrome = {
storage: {
sync: {
get: vi.fn((key: string) => Promise.resolve(
key in store ? { [key]: store[key] } : {})),
set: vi.fn((kv: any) => { Object.assign(store, kv); return Promise.resolve(); }),
remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }),
},
},
};
return store;
}
describe('color-scheme storage', () => {
beforeEach(() => {
// happy-dom provides document globally; reset inline styles between tests
document.documentElement.removeAttribute('style');
});
it('load returns defaults when storage is empty', async () => {
mockChromeStorage();
const scheme = await loadColorScheme();
expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR);
expect(scheme.symbol_color).toBe(DEFAULT_SYMBOL_COLOR);
});
it('load returns stored values when present', async () => {
mockChromeStorage({
password_display_scheme: { digit_color: '#123456', symbol_color: '#abcdef' },
});
const scheme = await loadColorScheme();
expect(scheme.digit_color).toBe('#123456');
expect(scheme.symbol_color).toBe('#abcdef');
});
it('save round-trips', async () => {
mockChromeStorage();
await saveColorScheme({ digit_color: '#111111', symbol_color: '#222222' });
const scheme = await loadColorScheme();
expect(scheme).toEqual({ digit_color: '#111111', symbol_color: '#222222' });
});
it('reset removes the storage key', async () => {
const store = mockChromeStorage({
password_display_scheme: { digit_color: '#000000', symbol_color: '#ffffff' },
});
await resetColorScheme();
expect(store.password_display_scheme).toBeUndefined();
const scheme = await loadColorScheme();
expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR);
});
it('apply sets CSS custom properties on document.documentElement', async () => {
mockChromeStorage({
password_display_scheme: { digit_color: '#deadbe', symbol_color: '#feed00' },
});
await applyColorScheme();
const root = document.documentElement.style;
expect(root.getPropertyValue('--relicario-pwd-digit-color').trim()).toBe('#deadbe');
expect(root.getPropertyValue('--relicario-pwd-symbol-color').trim()).toBe('#feed00');
});
it('save rejects malformed hex values', async () => {
mockChromeStorage();
await expect(saveColorScheme({ digit_color: 'not-a-color', symbol_color: '#ffffff' }))
.rejects.toThrow();
});
});

View File

@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { execSync } from 'node:child_process';
import { resolve } from 'node:path';
import { ERROR_COPY, lookupErrorCopy } from '../error-copy';
const repoRoot = resolve(__dirname, '../../../..');
function discoverCodes(): Set<string> {
const out = execSync(
`grep -rohE "ok: false, error: '[^']+'" extension/src/service-worker/ \
--include="*.ts" --exclude-dir=__tests__`,
{ cwd: repoRoot, encoding: 'utf-8' },
);
const codes = new Set<string>();
for (const line of out.split('\n')) {
const m = line.match(/error: '([^']+)'/);
if (m) codes.add(m[1]);
}
return codes;
}
describe('ERROR_COPY', () => {
it('contains an entry for every error code returned by the service worker', () => {
const discovered = discoverCodes();
expect(discovered.size).toBeGreaterThan(0);
const missing: string[] = [];
for (const code of discovered) {
if (!ERROR_COPY[code]) missing.push(code);
}
expect(missing).toEqual([]);
});
it('lookupErrorCopy returns the mapped entry for known codes', () => {
const copy = lookupErrorCopy('vault_locked');
expect(copy.title).toBe('Vault locked');
expect(copy.body).toMatch(/unlock/i);
});
it('lookupErrorCopy falls back to a generic shape for unknown codes', () => {
const copy = lookupErrorCopy('made_up_code_xyz');
expect(copy.title).toBe('Something went wrong');
expect(copy.body).toContain('made_up_code_xyz');
});
});

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { colorizePassword, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER } from '../password-coloring';
describe('colorizePassword', () => {
function classes(frag: DocumentFragment): string[] {
return Array.from(frag.querySelectorAll('span')).map(s => s.className);
}
function texts(frag: DocumentFragment): string[] {
return Array.from(frag.querySelectorAll('span')).map(s => s.textContent ?? '');
}
it('returns empty fragment for empty input', () => {
const frag = colorizePassword('');
expect(frag.childNodes.length).toBe(0);
});
it('classifies a mixed-class run', () => {
const frag = colorizePassword('aB3$xY');
expect(classes(frag)).toEqual([PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER]);
expect(texts(frag)).toEqual(['aB', '3', '$', 'xY']);
});
it('all-letters produces a single letter span', () => {
const frag = colorizePassword('passwd');
expect(classes(frag)).toEqual([PWD_LETTER]);
expect(texts(frag)).toEqual(['passwd']);
});
it('all-digits produces a single digit span', () => {
const frag = colorizePassword('123456');
expect(classes(frag)).toEqual([PWD_DIGIT]);
expect(texts(frag)).toEqual(['123456']);
});
it('all-symbols produces a single symbol span', () => {
const frag = colorizePassword('!@#$%^');
expect(classes(frag)).toEqual([PWD_SYMBOL]);
expect(texts(frag)).toEqual(['!@#$%^']);
});
it('classifies unicode letters as letters', () => {
const frag = colorizePassword('áñü');
expect(classes(frag)).toEqual([PWD_LETTER]);
});
it('classifies whitespace as symbol', () => {
const frag = colorizePassword('a b');
expect(classes(frag)).toEqual([PWD_LETTER, PWD_SYMBOL, PWD_LETTER]);
expect(texts(frag)).toEqual(['a', ' ', 'b']);
});
it('representative password snapshot: aB3$xY7&_!', () => {
const frag = colorizePassword('aB3$xY7&_!');
expect(classes(frag)).toEqual([
PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER, PWD_DIGIT, PWD_SYMBOL,
]);
expect(texts(frag)).toEqual(['aB', '3', '$', 'xY', '7', '&_!']);
});
});

View File

@@ -0,0 +1,48 @@
export const DEFAULT_DIGIT_COLOR = '#2563eb';
export const DEFAULT_SYMBOL_COLOR = '#dc2626';
const STORAGE_KEY = 'password_display_scheme';
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
export interface ColorScheme {
digit_color: string;
symbol_color: string;
}
export const DEFAULT_SCHEME: ColorScheme = {
digit_color: DEFAULT_DIGIT_COLOR,
symbol_color: DEFAULT_SYMBOL_COLOR,
};
function isValid(s: ColorScheme): boolean {
return HEX_RE.test(s.digit_color) && HEX_RE.test(s.symbol_color);
}
export async function loadColorScheme(): Promise<ColorScheme> {
const result = await chrome.storage.sync.get(STORAGE_KEY);
const stored = result[STORAGE_KEY] as Partial<ColorScheme> | undefined;
if (!stored) return { ...DEFAULT_SCHEME };
return {
digit_color: typeof stored.digit_color === 'string' && HEX_RE.test(stored.digit_color)
? stored.digit_color : DEFAULT_DIGIT_COLOR,
symbol_color: typeof stored.symbol_color === 'string' && HEX_RE.test(stored.symbol_color)
? stored.symbol_color : DEFAULT_SYMBOL_COLOR,
};
}
export async function saveColorScheme(scheme: ColorScheme): Promise<void> {
if (!isValid(scheme)) {
throw new Error('Invalid color values; expected #rrggbb hex strings.');
}
await chrome.storage.sync.set({ [STORAGE_KEY]: scheme });
}
export async function resetColorScheme(): Promise<void> {
await chrome.storage.sync.remove(STORAGE_KEY);
}
export async function applyColorScheme(): Promise<void> {
const scheme = await loadColorScheme();
const root = document.documentElement.style;
root.setProperty('--relicario-pwd-digit-color', scheme.digit_color);
root.setProperty('--relicario-pwd-symbol-color', scheme.symbol_color);
}

View File

@@ -0,0 +1,102 @@
export interface ErrorCta {
label: string;
action?: 'unlock' | 'reload_extension' | 'open_setup';
}
export interface ErrorCopy {
title: string;
body: string;
cta?: ErrorCta;
}
const UNLOCK_CTA: ErrorCta = { label: 'Unlock vault', action: 'unlock' };
export const ERROR_COPY: Record<string, ErrorCopy> = {
vault_locked: {
title: 'Vault locked',
body: 'Unlock your vault to continue.',
cta: UNLOCK_CTA,
},
unauthorized_sender: {
title: 'Action not allowed',
body: 'This action is not allowed from here.',
},
unknown_message_type: {
title: 'Internal error',
body: 'The extension received an unknown request — try reloading.',
cta: { label: 'Reload extension', action: 'reload_extension' },
},
origin_mismatch: {
title: 'Wrong site',
body: 'This login belongs to a different site — refusing to leak credentials cross-origin.',
},
not_a_login: {
title: 'Not a login',
body: 'That item does not have a username and password to fill.',
},
no_totp: {
title: 'No 2FA on this item',
body: 'This item does not have a TOTP secret configured.',
},
invalid_sender_url: {
title: 'Cannot read tab URL',
body: 'The current tab has no recognizable URL — try reloading the page.',
},
tab_navigated: {
title: 'Tab changed',
body: 'The browser tab changed before the action could complete — try again.',
},
captured_tab_gone: {
title: 'Tab is gone',
body: 'The browser tab closed before the action could complete — try again.',
},
item_not_found: {
title: 'Item not found',
body: 'That item is no longer in the vault — it may have been deleted from another device.',
},
attachment_not_found: {
title: 'Attachment missing',
body: 'The attachment is referenced in the item but is not present in the vault.',
},
upload_failed: {
title: 'Upload failed',
body: 'Could not upload the attachment — check your connection and try again.',
},
download_failed: {
title: 'Download failed',
body: 'Could not download the attachment — check your connection and try again.',
},
'invalid base32 secret': {
title: 'Invalid secret',
body: 'The TOTP secret must be valid Base32 (letters A-Z and digits 2-7 only).',
},
'no items to import': {
title: 'Nothing to import',
body: 'The CSV did not contain any importable items.',
},
'no reference image stored locally': {
title: 'No reference image',
body: 'This device has no reference image saved locally — re-attach the device or restore from backup.',
},
'remote already contains a Relicario vault': {
title: 'Vault already exists',
body: 'The selected repository already contains a vault — use Attach existing instead of Create new.',
},
'Extension not configured. Run setup first.': {
title: 'Extension not configured',
body: 'Run setup before using this action.',
cta: { label: 'Open setup', action: 'open_setup' },
},
'Reference image not set. Run setup first.': {
title: 'Reference image missing',
body: 'Run setup to save your reference image.',
cta: { label: 'Open setup', action: 'open_setup' },
},
};
export function lookupErrorCopy(code: string): ErrorCopy {
return ERROR_COPY[code] ?? {
title: 'Something went wrong',
body: code,
};
}

View File

@@ -0,0 +1,35 @@
export const PWD_DIGIT = 'pwd-digit';
export const PWD_SYMBOL = 'pwd-symbol';
export const PWD_LETTER = 'pwd-letter';
type Class = typeof PWD_DIGIT | typeof PWD_SYMBOL | typeof PWD_LETTER;
function classify(ch: string): Class {
if (/^\d$/.test(ch)) return PWD_DIGIT;
if (/^\p{L}$/u.test(ch)) return PWD_LETTER;
return PWD_SYMBOL;
}
export function colorizePassword(text: string): DocumentFragment {
const frag = document.createDocumentFragment();
if (text.length === 0) return frag;
const codepoints = Array.from(text);
let runStart = 0;
let runClass = classify(codepoints[0]);
for (let i = 1; i <= codepoints.length; i++) {
const c = i < codepoints.length ? classify(codepoints[i]) : null;
if (c !== runClass) {
const span = document.createElement('span');
span.className = runClass;
span.textContent = codepoints.slice(runStart, i).join('');
frag.appendChild(span);
if (c !== null) {
runStart = i;
runClass = c;
}
}
}
return frag;
}

View File

@@ -38,6 +38,10 @@
/* Focus */ /* Focus */
--focus-ring: 0 0 0 2px var(--gold-ring); --focus-ring: 0 0 0 2px var(--gold-ring);
/* Password coloring (P1) */
--relicario-pwd-digit-color: #2563eb;
--relicario-pwd-symbol-color: #dc2626;
} }
* { * {
@@ -144,6 +148,36 @@ body {
margin-top: 8px; margin-top: 8px;
} }
.error-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 16px;
/* rgba channels derived from --danger (#ab2b20 = rgb(171, 43, 32)) */
border: 1px solid rgba(171, 43, 32, 0.4);
border-radius: 6px;
background: rgba(171, 43, 32, 0.08);
margin-top: 12px;
}
.error-block .error-title {
font-weight: 600;
color: var(--danger);
}
.error-block .error-body {
color: var(--text);
font-size: 12px;
text-align: center;
}
.error-block .error-cta {
margin-top: 6px;
}
/* Password character-class coloring */
.pwd-digit { color: var(--relicario-pwd-digit-color); }
.pwd-symbol { color: var(--relicario-pwd-symbol-color); }
.pwd-letter { color: inherit; }
/* Buttons */ /* Buttons */
.btn { .btn {
display: inline-block; display: inline-block;
@@ -1592,6 +1626,20 @@ textarea {
@media (max-width: 720px) { @media (max-width: 720px) {
.form-grid { grid-template-columns: 1fr; } .form-grid { grid-template-columns: 1fr; }
} }
/* P3: lower form sections constrained to the same envelope as .form-grid.
Gated on surface === 'fullscreen' in login.ts; popup unaffected. */
.form-lower {
max-width: 960px;
margin: 0 auto;
}
.form-lower > .form-group,
.form-lower > .disclosure,
.form-lower > .attachments-disclosure,
.form-lower > .form-actions {
width: 100%;
}
.form-col { .form-col {
padding: 14px 16px; padding: 14px 16px;
} }

View File

@@ -9,6 +9,7 @@ import type {
ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest, ItemId, ItemType, ManifestEntry, Item, VaultSettings, GeneratorRequest,
} from '../shared/types'; } from '../shared/types';
import { registerHost } from '../shared/state'; import { registerHost } from '../shared/state';
import { lookupErrorCopy, type ErrorCta } from '../shared/error-copy';
import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } from '../shared/glyphs'; import { GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK } from '../shared/glyphs';
import { renderItemDetail } from '../popup/components/item-detail'; import { renderItemDetail } from '../popup/components/item-detail';
import { renderItemForm } from '../popup/components/item-form'; import { renderItemForm } from '../popup/components/item-form';
@@ -19,6 +20,7 @@ import { renderVaultSettings as renderVaultSettingsView } from '../popup/compone
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history'; import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel'; import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
import { renderImportPanel, teardown as teardownImport } from './components/import-panel'; import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
import { applyColorScheme } from '../shared/color-scheme';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@@ -41,6 +43,21 @@ function escapeHtml(str: string): string {
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
function renderErrorBlock(code: string | null | undefined): string {
if (!code) return '';
const copy = lookupErrorCopy(code);
const ctaHtml = copy.cta
? `<button class="btn btn-primary error-cta" data-cta="${escapeHtml(copy.cta.action ?? '')}">${escapeHtml(copy.cta.label)}</button>`
: '';
return `
<div class="error error-block">
<div class="error-title">${escapeHtml(copy.title)}</div>
<div class="error-body">${escapeHtml(copy.body)}</div>
${ctaHtml}
</div>
`;
}
function typeIcon(t: ItemType): string { function typeIcon(t: ItemType): string {
switch (t) { switch (t) {
case 'login': return '\u{1F511}'; // key case 'login': return '\u{1F511}'; // key
@@ -199,7 +216,7 @@ function renderLockScreen(app: HTMLElement): void {
<div class="vault-lock-screen__form"> <div class="vault-lock-screen__form">
<input type="password" id="vault-passphrase" placeholder="passphrase" autocomplete="off" /> <input type="password" id="vault-passphrase" placeholder="passphrase" autocomplete="off" />
<button class="btn btn-primary" id="vault-unlock-btn" style="width:100%;">unlock</button> <button class="btn btn-primary" id="vault-unlock-btn" style="width:100%;">unlock</button>
${state.error ? `<div class="error" style="text-align:center;">${escapeHtml(state.error)}</div>` : ''} ${renderErrorBlock(state.error)}
</div> </div>
</div> </div>
`; `;
@@ -592,6 +609,36 @@ async function loadManifest(): Promise<void> {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
await applyColorScheme();
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'sync' && 'password_display_scheme' in changes) {
void applyColorScheme();
}
});
// Delegated handler for .error-cta buttons — set up once on the stable root.
const app = document.getElementById('vault-app')!;
app.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.error-cta');
if (!btn) return;
const cta = btn.dataset.cta as ErrorCta['action'];
switch (cta) {
case 'unlock': {
document.getElementById('vault-passphrase')?.focus();
break;
}
case 'open_setup': {
void chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
break;
}
case 'reload_extension': {
chrome.runtime.reload();
break;
}
}
});
// Check if already unlocked // Check if already unlocked
const resp = await sendMessage({ type: 'is_unlocked' }); const resp = await sendMessage({ type: 'is_unlocked' });
if (resp.ok) { if (resp.ok) {

View File

@@ -12,5 +12,5 @@
"baseUrl": "." "baseUrl": "."
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "wasm", "src/**/__tests__/**"] "exclude": ["node_modules", "dist", "wasm", "src/**/__tests__/**", "src/__stubs__/**"]
} }

View File

@@ -1,6 +1,14 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({ export default defineConfig({
resolve: {
alias: {
// Stub the runtime-only WASM module so unit tests can import setup.ts.
'../relicario_wasm.js': path.resolve(__dirname, 'src/__stubs__/relicario_wasm.stub.ts'),
'relicario-wasm': path.resolve(__dirname, 'src/__stubs__/relicario_wasm.stub.ts'),
},
},
test: { test: {
environment: 'happy-dom', environment: 'happy-dom',
include: ['src/**/__tests__/**/*.test.ts'], include: ['src/**/__tests__/**/*.test.ts'],

1701
tools/relay/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
tools/relay/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "@relicario/relay",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "npx tsx server.ts",
"test": "node --import=tsx/esm --test queue.test.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.10.0"
},
"devDependencies": {
"tsx": "^4.19.0",
"@types/node": "^22.0.0"
}
}

58
tools/relay/queue.test.ts Normal file
View File

@@ -0,0 +1,58 @@
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { RelayQueue, isRole } from "./queue.ts";
describe("RelayQueue", () => {
let q: RelayQueue;
beforeEach(() => {
q = new RelayQueue();
});
it("post + read roundtrip returns the message with correct fields", () => {
q.post("dev-b", "pm", "status", "Task P4 DONE");
const msgs = q.read("pm");
assert.equal(msgs.length, 1);
assert.equal(msgs[0].from, "dev-b");
assert.equal(msgs[0].to, "pm");
assert.equal(msgs[0].kind, "status");
assert.equal(msgs[0].body, "Task P4 DONE");
assert.ok(typeof msgs[0].id === "string" && msgs[0].id.length > 0);
assert.ok(typeof msgs[0].ts === "string");
});
it("consume-once: second read returns empty", () => {
q.post("dev-a", "pm", "question", "Should I use approach A?");
q.read("pm");
const second = q.read("pm");
assert.deepEqual(second, []);
});
it("list_pending does not drain inbox", () => {
q.post("dev-b", "pm", "directive", "PROCEED");
const before = q.pending("pm");
assert.equal(before.count, 1);
const after = q.read("pm");
assert.equal(after.length, 1);
});
it("FIFO ordering across multiple senders", () => {
q.post("dev-a", "pm", "status", "first");
q.post("dev-b", "pm", "status", "second");
q.post("dev-a", "pm", "question", "third");
const msgs = q.read("pm");
assert.equal(msgs.length, 3);
assert.equal(msgs[0].body, "first");
assert.equal(msgs[1].body, "second");
assert.equal(msgs[2].body, "third");
});
it("isRole rejects unknown strings", () => {
assert.ok(isRole("pm"));
assert.ok(isRole("dev-a"));
assert.ok(isRole("dev-b"));
assert.ok(!isRole("dev-c"));
assert.ok(!isRole(""));
assert.ok(!isRole("PM"));
});
});

55
tools/relay/queue.ts Normal file
View File

@@ -0,0 +1,55 @@
import { randomUUID } from "node:crypto";
export type Role = "pm" | "dev-a" | "dev-b";
export type MessageKind = "status" | "question" | "directive" | "free";
export interface RelayMessage {
id: string;
from: Role;
to: Role;
kind: MessageKind;
body: string;
ts: string;
}
const KNOWN_ROLES = new Set<string>(["pm", "dev-a", "dev-b"]);
export function isRole(s: string): s is Role {
return KNOWN_ROLES.has(s);
}
export class RelayQueue {
private readonly queues = new Map<Role, RelayMessage[]>([
["pm", []],
["dev-a", []],
["dev-b", []],
]);
post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage {
const msg: RelayMessage = {
id: randomUUID(),
from,
to,
kind,
body,
ts: new Date().toISOString(),
};
this.queues.get(to)!.push(msg);
return msg;
}
read(forRole: Role): RelayMessage[] {
const inbox = this.queues.get(forRole)!;
const messages = [...inbox];
inbox.length = 0;
return messages;
}
pending(forRole: Role): { count: number; kinds: MessageKind[] } {
const inbox = this.queues.get(forRole)!;
return {
count: inbox.length,
kinds: inbox.map((m) => m.kind),
};
}
}

159
tools/relay/server.ts Normal file
View File

@@ -0,0 +1,159 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import http from "node:http";
import { RelayQueue, isRole } from "./queue.ts";
const PORT = 7331;
const queue = new RelayQueue();
const mcpServer = new Server(
{ name: "relay", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
const TOOLS = [
{
name: "post_message",
description:
"Push a message to a recipient's inbox. Returns the assigned message id.",
inputSchema: {
type: "object" as const,
properties: {
from: {
type: "string",
enum: ["pm", "dev-a", "dev-b"],
description: "Your role name",
},
to: {
type: "string",
enum: ["pm", "dev-a", "dev-b"],
description: "Recipient role name",
},
kind: {
type: "string",
enum: ["status", "question", "directive", "free"],
description: "Message type matching the coordination protocol",
},
body: {
type: "string",
description: "Message body — freeform markdown, typically the full formatted block",
},
},
required: ["from", "to", "kind", "body"],
},
},
{
name: "read_messages",
description:
"Pop and return all pending messages for this recipient. Inbox is empty after this call (consume-once).",
inputSchema: {
type: "object" as const,
properties: {
for: {
type: "string",
enum: ["pm", "dev-a", "dev-b"],
description: "Your role name",
},
},
required: ["for"],
},
},
{
name: "list_pending",
description:
"Return count and kinds of pending messages without consuming them.",
inputSchema: {
type: "object" as const,
properties: {
for: {
type: "string",
enum: ["pm", "dev-a", "dev-b"],
description: "Your role name",
},
},
required: ["for"],
},
},
];
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const a = args as Record<string, string>;
if (name === "post_message") {
if (!isRole(a.from)) {
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.from}"` }], isError: true };
}
if (!isRole(a.to)) {
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.to}"` }], isError: true };
}
const kind = a.kind as "status" | "question" | "directive" | "free";
const msg = queue.post(a.from, a.to, kind, a.body);
const ts = new Date(msg.ts).toTimeString().slice(0, 8);
const preview = a.body.slice(0, 60).replace(/\n/g, " ");
const ellipsis = a.body.length > 60 ? "..." : "";
process.stdout.write(`[${ts}] ${a.from}${a.to} [${kind}] "${preview}${ellipsis}"\n`);
return { content: [{ type: "text" as const, text: JSON.stringify({ id: msg.id }) }] };
}
if (name === "read_messages") {
if (!isRole(a.for)) {
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
}
const messages = queue.read(a.for);
return { content: [{ type: "text" as const, text: JSON.stringify(messages) }] };
}
if (name === "list_pending") {
if (!isRole(a.for)) {
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
}
const result = queue.pending(a.for);
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
}
return {
content: [{ type: "text" as const, text: `Error: unknown tool "${name}"` }],
isError: true,
};
});
const transports = new Map<string, SSEServerTransport>();
const httpServer = http.createServer(async (req, res) => {
try {
if (req.method === "GET" && req.url === "/sse") {
const transport = new SSEServerTransport("/message", res);
transports.set(transport.sessionId, transport);
transport.onclose = () => transports.delete(transport.sessionId);
await mcpServer.connect(transport);
} else if (req.method === "POST" && req.url?.startsWith("/message")) {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
const sessionId = url.searchParams.get("sessionId") ?? "";
const transport = transports.get(sessionId);
if (transport) {
await transport.handlePostMessage(req, res);
} else {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "session not found" }));
}
} else {
res.writeHead(404).end("not found");
}
} catch (err) {
console.error("[relay] error:", err);
if (!res.headersSent) res.writeHead(500).end(String(err));
}
});
httpServer.listen(PORT, "127.0.0.1", () => {
console.log(`[relay] server ready on :${PORT}`);
console.log(`[relay] tools: post_message, read_messages, list_pending`);
console.log(`[relay] waiting for connections — Ctrl-C to stop`);
});

99
tools/relay/start.sh Executable file
View File

@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
PORT=7331
MODE="manual"
for arg in "$@"; do
case "$arg" in
--tmux) MODE="tmux" ;;
--kitty) MODE="kitty" ;;
--manual) MODE="manual" ;;
*) echo "Unknown option: $arg" >&2; echo "Usage: $0 [--manual|--tmux|--kitty]" >&2; exit 1 ;;
esac
done
# Port check
if lsof -ti:"$PORT" &>/dev/null; then
echo "Error: port $PORT is already in use."
echo "Relay already running? Kill it with: kill \$(lsof -ti:$PORT)"
exit 1
fi
# Install deps (no-op if node_modules current)
cd "$SCRIPT_DIR"
npm install --silent
# Discover latest coordination prompts for instructions
COORD_DIR="$REPO_ROOT/docs/superpowers/coordination"
PM_PROMPT="$(ls -t "$COORD_DIR"/*-pm-prompt.md 2>/dev/null | head -1 || echo "(none found — run multi-agent-kickoff skill first)")"
DEV_A_PROMPT="$(ls -t "$COORD_DIR"/*-dev-a-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
DEV_B_PROMPT="$(ls -t "$COORD_DIR"/*-dev-b-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
print_manual_instructions() {
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ RELAY SERVER — MULTI-AGENT LIFT LAUNCHER ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
echo "Open 3 new terminals. In each, start Claude Code and paste"
echo "the content BELOW the '---' line from the corresponding file."
echo ""
echo " Terminal 1 (PM): cat '$PM_PROMPT'"
echo " Terminal 2 (Dev A): cat '$DEV_A_PROMPT'"
echo " Terminal 3 (Dev B): cat '$DEV_B_PROMPT'"
echo ""
echo "This terminal becomes the relay log. Keep it open."
echo ""
echo "══════════════════════════════════════════════════════════════"
}
launch_tmux() {
SESSION="relay-lift"
tmux new-session -d -s "$SESSION" -n "relay" \
"cd '$SCRIPT_DIR' && npx tsx server.ts"
tmux new-window -t "$SESSION:" -n "pm" "cd '$REPO_ROOT' && claude"
tmux new-window -t "$SESSION:" -n "dev-a" "cd '$REPO_ROOT' && claude"
tmux new-window -t "$SESSION:" -n "dev-b" "cd '$REPO_ROOT' && claude"
echo ""
echo "[relay] Opened tmux session '$SESSION' with 4 windows: relay, pm, dev-a, dev-b."
echo "[relay] Paste the kickoff prompt into each Claude window."
echo " Prompts:"
echo " PM: $PM_PROMPT"
echo " Dev A: $DEV_A_PROMPT"
echo " Dev B: $DEV_B_PROMPT"
echo ""
tmux attach-session -t "$SESSION"
}
launch_kitty() {
kitty @ launch --new-tab --tab-title "relay" -- \
bash -c "cd '$SCRIPT_DIR' && npx tsx server.ts"
kitty @ launch --new-window --window-title "PM" -- \
bash -c "cd '$REPO_ROOT' && claude"
kitty @ launch --new-window --window-title "Dev-A" -- \
bash -c "cd '$REPO_ROOT' && claude"
kitty @ launch --new-window --window-title "Dev-B" -- \
bash -c "cd '$REPO_ROOT' && claude"
echo ""
echo "[relay] Opened kitty tab 'relay' + 3 windows (PM, Dev-A, Dev-B)."
echo " Paste the kickoff prompts into each Claude window."
echo " PM: $PM_PROMPT"
echo " Dev A: $DEV_A_PROMPT"
echo " Dev B: $DEV_B_PROMPT"
}
case "$MODE" in
manual)
print_manual_instructions
exec npx tsx "$SCRIPT_DIR/server.ts"
;;
tmux)
launch_tmux
;;
kitty)
launch_kitty
;;
esac

10
tools/relay/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true
},
"include": ["*.ts"]
}