Persona
A Jungian type test in the Omarchy bar. Thirty-two pairs of opposites, one at a time, fullscreen; four letters in the corner afterwards, and how rare they are among the tens of thousands of people in openjung.org's open dataset.

The bar carries a mask and four letters; the panel and the fullscreen result
page are in screenshots/.
The instrument is the Open Extended Jungian Type Scales (OEJTS), an openly licensed (CC BY-NC-SA 4.0) alternative to the commercial MBTI instruments. The questions, the scoring and the aggregate comparison all come from openjung.org's public API. This plugin is the interface, not the psychometrics.
Install
omarchy plugin add https://github.com/ya-luotao/omarchy-persona.git --enable
Nothing else is needed: curl and coreutils head are already on an Omarchy
box, and they are the whole dependency list. xdg-open is used only by the
optional "open this result on openjung.org" action.
Press the widget to open the panel, or bind the test directly:
omarchy-shell luotao.persona test # start or resume the test
omarchy-shell luotao.persona toggle # the panel
Middle-click the widget does the same as test.
Uninstall
omarchy plugin remove luotao.persona
That unregisters the plugin and removes it from the bar. Two things it does not touch, because they are yours:
rm -rf ~/.local/state/omarchy/persona # your result, your history, the caches
That directory is 0700 and the files inside it 0600: the answers and the
type they produced are nobody else's business, including other users of the
same machine.
and any keybinding you added yourself, which lives in your own Hyprland config:
grep -rn "luotao.persona" ~/.config/hypr/
Nothing else is written anywhere. The plugin creates no files outside that one state directory and changes no configuration of yours.
Taking it
The test is fullscreen and keyboard-only:
| key | |
|---|---|
1–5 |
answer, and move on |
← / Backspace |
go back and change an answer |
→ |
jump forward to the first unanswered question |
Esc |
leave — everything answered so far is kept |
r |
on the result page, take it again |
s |
on the result page, open it on openjung.org |
Which dimension a question measures is never shown. Knowing that a question is scoring J against P is exactly what makes people answer the type they would rather be.
Leaving halfway is fine. The bar shows 19/32 and reopening carries on from
there; nothing is sent anywhere until all 32 are in.
Settings
| key | default | |
|---|---|---|
locale |
en |
question language: en, zh, ja, ko, zh-tw |
showLetters |
true |
show the four letters next to the mask in the bar |
contribute |
false |
add this result to openjung.org's anonymous research dataset |
contribute is off by default and is the only setting that sends anything
beyond the request needed to score your own test. With it on, the answers and
the derived scores are stored by openjung.org for aggregate statistics — no
name, no account, no address. It is the same dataset this plugin reads back to
tell you how common your type is, which is the argument for turning it on and
the reason it is not on already.
What it does not do
It never scores your answers itself. Every type shown by this plugin came
back from POST /api/calculate. A half-finished test shows a count and nothing
else — never an estimate, never a projected type.
That restraint is deliberate, and measured. OEJTS looks trivial to
reimplement — eight questions a dimension, sum the answers — but openjung.org's
own documentation disagrees with itself about the resulting range: /api-docs
says 8–40 per dimension, /research/data says 0–48. Against the live endpoint
it is 8–40 (all-1s returns 8, all-5s returns 40, all-3s returns 24 and lands on
the left trait, matching the documented > 24 cut). But that is a measurement
of today's server, not a promise about tomorrow's, and a plugin quietly
disagreeing with the site it quotes is worse than one that has to wait for a
reply.
So the arithmetic lives on their side, and this side checks their work: every response has its four scores compared against its own type letters, and a result whose numbers and letters disagree is refused rather than drawn.
What a bad day at openjung.org costs
The bar widget is keepLoaded: it lives as long as the session. So the question
is not whether the happy path parses, but what a hostile, compromised or merely
broken endpoint can do to a process nobody restarts without losing their
desktop. Three answers, each measured rather than argued.
Bytes are bounded before this process holds them. QML's XMLHttpRequest
works here — probe-net.qml measures it at ~2.5s per request against curl's
~1.2s, with no subprocess to pay for — and the plugin uses curl anyway, because
responseText has no byte limit and is resident before any JavaScript can
measure it. Instead every request is curl --proto "=https" --max-filesize <cap> --max-time 20 … | head -c <cap+1>, and every file read is head -c <cap+1>. The argv is built in Persona.js so it can be run in a test rather
than described in a README.
Two independent over-cap signals, because measurement contradicted the
documentation: --max-filesize is documented as having no effect when the
length is not declared, but curl 8.21 does abort a chunked transfer once the
received bytes pass the limit — leaving a body of exactly the cap, which no
length check can tell from a legitimate response of that size. So the verdict
reads curl's exit code as well, and head -c cap+1 remains the guarantee that
does not depend on curl's behaviour at all.
Caps: 64 KiB for the question set and a result (21x and 46x their real size), 256 KiB for the research aggregate, 64 KiB for this plugin's own state file. A transfer is refused whole rather than truncated, so a cut-off response can never be parsed as a short one.
The answers never appear in argv. POST /api/calculate carries them on
stdin (--data-binary @-). Argv is world-readable on this machine; a person's
32 answers are not something to publish to every other process on it.
The profile on disk is private, and the attacker is another process running as
you. self.json holds the answers, the resulting type and a history of past
types. Created with a plain mkdir and cat >, the usual 022 umask makes it
0644 — readable by every other local user on a machine with a traversable home.
So the write sets the mode rather than inheriting it (umask 077, plus a chmod
that repairs a directory or file left behind by an earlier version), and it is
atomic: a temporary sibling and a rename, so a crash or a full disk mid-write
costs the previous contents nothing.
0700 on the directory keeps other users out. It says nothing about another
process running as the same user — a compromised application, or another
plugin — and that is the process the rest of this has to survive:
- The temporary sibling's name comes from
mktemp, not from the target. Deriving it (self.json.new) made it an arbitrary-write primitive: a same-user process pre-places a symlink at a name it can predict,cat >follows it and truncates whatever it points at, thechmodapplies to that file, and the rename tidies the link away.mktempcreates the file itself withO_CREAT|O_EXCLand mode 0600, so there is no name to pre-place. - The directory is checked for being a symlink before anything is done to it,
and again after
mkdir -p, becausechmodfollows one and the parent (~/.local/state/omarchy) is shared with every other Omarchy plugin. The startup repair skips symlinked files for the same reason. - Every read refuses a symlink, requires a regular file, and carries a deadline.
A FIFO planted at a cache path blocks
headforever on open, and the reader belongs to a shell that lives as long as the session: the state would never load and the process would never be reaped. The gate and the open are two syscalls with a gap between them, so the deadline is there as well rather than instead.
Every sink is plain text. QML's default Text.AutoText reads a value
beginning with markup as rich text, which means a trait containing <b> would
be styled and one containing <img src=…> would make the shell fetch it.
Everything on both pages comes from openjung.org, so every Text is
Text.PlainText, and the three strings handed to components whose Text this
plugin does not own — a bar tooltip, a panel hero — are stripped of markup
delimiters first. wrapMode is Text.Wrap, not Text.WordWrap: word wrapping
cannot break an unbroken run, and a 2,000-character one painted straight out of
the panel until it was.
Post-parse bounds are a second, separate defence, because a response can sit inside the transport cap and still carry a 60 KB nickname: traits, names and list items are cut at 400 characters, the description at 2,000, lists at 12 items, and the compatible-types row keeps only strings that really are type codes.
tests/hostile.js runs the real generated argv against a local HTTPS server
built to break it — a 20 MiB chunked flood, a declared 20 MiB length, a stalled
transfer, a body that tries to forge curl's own status marker, an untrusted
certificate, a plain-http base, a file:// base, and a bloated-but-legal
response. It speaks HTTPS with a throwaway CA rather than testing a weakened
command.
The comparison numbers
/api/research/stats publishes a type distribution and, per dimension, six
histogram buckets over all recorded tests. Your score is placed by bucket
membership — "band 19-24, with 17% of respondents" — and deliberately not as a
percentile: six buckets do not support interpolating a position inside one.
The aggregate is cached for a day, and the widget never fetches on shell start — only once you open the panel. The 32 questions are cached per language on first fetch, so after that the test itself works with no network at all. Only scoring needs the server.
Two bugs in the API, worked around
Both were found by running it, and both are re-checked by tests/live.js so the
workaround can be removed if the API is fixed.
- The share URL 404s for English.
/api/calculatereturnshttps://openjung.org/en/result/ESFJ-21-17-19-15, which does not resolve; the site serves the default locale at the bare path (/result/…, 200) and other locales under their prefix (/zh/result/…, 200).Persona.shareUrl()drops the/ensegment and leaves every other locale alone. /research/datadocuments a 0–48 score range. The API returns 8–40. The published histogram buckets (0-12…37-48) are simply wider than the real range at both ends, so bucket membership still works; nothing else relies on the documented range.
Layout
Persona.js |
the model: state on disk, walking a sitting, reading responses, placing a score. Pure JS, no Qt — and it cannot score. |
Backend.qml |
the whole I/O surface: three GETs, one POST, one small file. |
Transfer.qml |
one bounded request, and the verdict on it. Waits for both streams, because the shell's exit status is head's and is always 0. |
BarWidget.qml |
the mask, the letters, and the panel. |
TestOverlay.qml |
the fullscreen test and the result page. |
probe-net.qml |
does this QML engine have XMLHttpRequest, and what does it cost against curl? |
probe-backend.qml |
Backend.qml end to end against the live API. |
State lives in ~/.local/state/omarchy/persona/: self.json (the sitting, the
last result, a short history of past types) and the two caches. Deleting it
resets everything.
Tests
mise x node@24 -- node tests/exports.js # every top-level name is exported
mise x node@24 -- node tests/persona.js # 642 checks against captured fixtures
mise x node@24 -- node tests/hostile.js # 96 checks against a hostile server
mise x node@24 -- node tests/live.js # 53 checks: is this still the API?
qs -p probe-net.qml # what transports exist, and what they cost
PERSONA_STATE=/tmp/persona-probe qs -p probe-backend.qml
tests/persona.js and tests/hostile.js are offline: the first runs against
responses captured from the real API in tests/fixtures/, the second against a
local HTTPS server (needs openssl and curl; makes no outbound connections).
tests/live.js needs the network and hits a free service somebody else pays
for — it is not part of the default run, it sends save: false on every request,
and it exists to fail loudly the day the endpoint changes shape. It also runs
the plugin's own bounded command against the real endpoint, because the shell
never uses node's fetch and a command that works only against the test server
would pass everything else.
Credit and licence
- The instrument: Open Extended Jungian Type Scales (Open Psychometrics), CC BY-NC-SA 4.0.
- The questions, scoring and aggregate data: openjung.org (API docs, open data). The aggregate dataset is CC BY 4.0.
- This plugin: MIT.
Type indicators are not diagnostic instruments, and OEJTS does not claim to be one. Four letters are a conversation starter, not a description of a person.