Design for a multi-turn conversation with a language model inside the launcher. The box you already type in becomes the chat input; the card below it becomes the transcript. One keyword opens it, Enter sends, the answer streams back word by word.
This document is the full plan: architecture, wire protocol, provider registry, session model, performance budgets, failure handling, and the build order. It is written to be implemented from, and to be checked against later.
Two halves of this feature already shipped, on opposite sides of the gap:
Ctrl+Enter "Ask a model" — single-shot Q&A. oxy.json's
askProviders list names CLI commands (claude, codex, gemini,
ollama, aichat, mods); the first whose when probe passes runs the
question and streams stdout line-by-line into ResultAnswer.qml, a card
with a blinking caret. askProvider pins one. Stateless: every Ctrl+Enter
is a new question with no memory of the last.
do: agent chat — multi-turn, streaming, Enter-sends-and-clears. A
Python daemon owns the conversation behind a unix socket; the launcher
pushes {epoch, query} in and {epoch, rows} come back out. Turns
collapse as they finish; refusals render red; the session evaporates after
idle. But it is agentic — claude/codex/gemini CLIs with tool
permissions, seconds of startup, built to act on the machine, not to
converse.
This feature is the middle: a multi-turn conversation — ask, follow up, ask again — over whatever model the machine can already reach: subscription CLIs the user has logged into, API-keyed endpoints, or a local model. No tools, no permissions, no daemon.
| Ctrl+Enter ask (today) | do: agent (today) |
ask: chat (this) |
|
|---|---|---|---|
| turns | single | multi | multi |
| model does | answers | acts on the machine | answers |
| transport | one CLI call | daemon + socket | one process per turn |
| startup | fast | seconds | fast |
| backends | CLI list | claude/codex/gemini | registry: subs, keys, local |
Ctrl+Enter and ask: are the same session. A question fired with
⌃↵ from git: is a turn in the same conversation a later ask: continues.
Every decision below falls out of four constraints:
- Zero per-keystroke cost. Typing is free. The only process that may ever run is the one Enter explicitly starts.
- Bounded redraws. Token rate is the model's problem; the UI updates at most ~8 times a second no matter how fast the stream arrives.
- Nothing runs while idle. No daemon, no background socket, no watcher. State lives in a file; processes live exactly as long as one HTTP request takes.
- Never silently fail. Every failure — no provider, dead endpoint, expired auth, mid-stream kill — lands as text in the card, naming the fix, not as an empty card or a spinner that never stops.
Chat completion APIs are stateless. A "conversation" is a messages[]
array resent in full with every request — so the daemon the conversation
seems to need is a file. The entire backend is:
bin/oxy-ask — one Python process, alive only for the duration of a turn
Enter in ask: scope (or Ctrl+Enter anywhere)
│
│ OXY_ASK_PROMPT=<text> oxy-ask ask
│ │
│ ├─ read ~/.local/state/omarchy/oxy-ask.json
│ │ {provider, model, handle, messages[], turns[], touched}
│ ├─ resolve provider (session's choice, else autodetect)
│ ├─ transport.send():
│ │ api kind → POST {endpoint}/chat/completions, SSE
│ │ cli kind → spawn CLI with its resume flag
│ ├─ stream tokens → framed stdout ──────────────┐
│ ├─ append turn to messages[]/turns[], save │
│ └─ exit │
▼ │
askProcess (QML Process) │
stdout: SplitParser ──► line buffer ──► 120ms flush ─┘
──► answerDone + answerTail (two string properties)
▼
ResultAnswer.qml
collapsed turns (ListModel, append-only)
live question + streaming answer + caret
header: provider · model · endpoint host
Why not a daemon (the do: pattern): a daemon earns its keep when the
server holds state a client can't — the agent's tool session. Here the
"state" is a JSON array we already own, and the connection is per-request
anyway. A file plus a per-turn process gives the same continuity with zero
idle memory, zero socket lifecycle, and a failure surface of exactly one
process at a time.
Why oxy-ask is Python and not curl+jq: SSE parsing, two wire protocols
(OpenAI + Anthropic shapes), four CLI continuation dialects, session
management, and graceful SIGTERM handling are all ~real logic. One
python3 stdlib script keeps it dependency-free on a stock Omarchy
install.
The constraint that shapes this section: SplitParser (the only
incremental stdout reader Quickshell gives us) emits on newline only.
A token stream like "Hel", "lo", " wor", "ld" produces nothing
until the first paragraph break — visible as the card sitting frozen for a
second or two, which reads exactly like a stalled request.
So oxy-ask frames its output:
| Output | Frame | Launcher behavior |
|---|---|---|
| complete line | text\n |
append to answerDone |
| in-progress tail | text\x00\n |
replace answerTail |
The NUL byte is the "this is a rewrite, not a new line" marker. The
displayed text is answerDone + answerTail; on each NUL-framed flush the
tail is swapped, giving a word-level typing effect. oxy-ask emits tail
frames at most every ~120ms and always on a real newline, so the final
flush is a complete-line frame and answerTail drains to empty.
stream: "The " → "The answer" → "The answer is\n" → "The answer is\n42"
wire: The \x00\n The answer\x00\n The answer is\n 42\x00\n
card: "The " "The answer" "The answer is" + "42"
Coalescing on the QML side too. Even at 8 tail-frames/sec, appending
to a bound string property costs a text re-layout per flush. Incoming
lines accumulate in a plain JS array; a 120ms Timer joins and appends.
Two string properties change per flush cycle, one Text re-lays out —
that is the entire per-frame cost, decoupled from token rate:
model at 10 tok/s → ~8 UI updates/s
model at 200 tok/s → ~8 UI updates/s (same)
Markdown: while streaming, the body is Text.PlainText — raw **
visible mid-type, which is honest "it's typing" and matches how agentic
harnesses render their streams. ~400ms after the stream ends, the
component swaps textFormat to Text.MarkdownText — one parse, one
layout, formatted result. "markdown": false in settings keeps it plain
forever. Live re-parse-per-flush was rejected: Qt's markdown parser runs
over the whole document each time, making the cost O(n²) in answer
length for flicker nobody asked for.
Why a sentinel and not a second channel: the alternatives are a socket (a daemon, rejected) or emitting each token as a complete line (breaks words, breaks markdown). The sentinel keeps one stream, one parser, one code path — and a provider that emits no newlines still streams visibly.
oxy-ask owns every provider's quirks behind one interface. The launcher
asks for "a turn" and "a probe"; it never knows which backend answered.
| id | transport | multi-turn mechanism | auth source | stream shape |
|---|---|---|---|---|
claude |
claude -p --output-format stream-json --verbose |
--resume <session_id>; id read from the result event |
claude CLI login (subscription) or ANTHROPIC_API_KEY |
NDJSON deltas |
codex |
codex exec --json |
codex exec resume --last or <id>; --all not used (cwd-scoped is correct for a launcher) |
codex CLI auth (ChatGPT sub) or OPENAI_API_KEY |
JSONL events → agent_message text |
gemini |
gemini -p (prompt via stdin) |
gemini --resume (latest) or --resume <index|uuid> + stdin prompt |
gemini CLI OAuth or GEMINI_API_KEY |
plain stdout stream |
opencode |
opencode run --format json |
--continue / --session <id> |
opencode auth — its own provider registry |
JSON events |
zai |
POST api.z.ai/api/coding/paas/v4/chat/completions |
messages[] replay |
ZAI_API_KEY or ask.providers.zai.key |
SSE |
openai |
POST api.openai.com/v1/chat/completions |
messages[] replay |
OPENAI_API_KEY |
SSE |
anthropic-api |
POST api.anthropic.com/v1/messages |
messages[] replay |
ANTHROPIC_API_KEY |
SSE (content_block_delta) |
ollama |
POST localhost:11434/v1/chat/completions |
messages[] replay |
none — local | SSE |
lmstudio |
POST localhost:1234/v1/chat/completions |
messages[] replay |
none — local | SSE |
custom |
POST ask.endpoint |
messages[] replay |
ask.key (env:NAME or literal) |
SSE |
agy |
probe-only, experimental | TBD — no documented non-interactive prompt interface | antigravity CLI auth | TBD |
api kind — messages[] replay. The session file's messages[] is
the context: [system, user, assistant, user, ...], POSTed in full each
turn. Works identically for z.ai, OpenAI, OpenRouter, Groq, ollama, LM
Studio, llama.cpp, and any custom endpoint. Provider differences are
three fields: URL, auth header, and which JSON path holds the streamed
delta (choices[0].delta.content vs Anthropic's
content_block_delta.delta.text).
cli kind — native session handle. The CLI's own continuation
mechanism carries context; our session file stores a handle
(claude's session_id, "latest" for gemini/codex where no id is
exposed, opencode's session id). turns[] is still kept — it is what
the card renders — but it is a display copy, not the context.
claude. claude -p --output-format stream-json --verbose emits
newline-delimited JSON events; text deltas arrive as
stream_event/assistant payloads and the final result event carries
session_id — captured into handle for --resume on the next turn.
--model <id> accepts aliases (sonnet, opus, haiku) and full IDs.
Models list: hardcoded defaults (sonnet, opus, haiku) plus free-form
— the CLI validates.
codex. codex exec --json "<prompt>" emits JSONL state-change events;
the assistant text lives in agent_message items. Continuation is
codex exec resume --last "<prompt>" (cwd-scoped — correct here, and the
documented behavior). resume <id> is used when an id is captured.
--model takes the model name directly.
gemini. gemini -p with the prompt piped on stdin (never argv —
same discipline as oxy-agent's stdin feed). Continuation is
gemini --resume (latest) or --resume <index|uuid>, with the prompt
piped in — supported in non-interactive mode. Output is plain streamed
stdout, the simplest case in the registry.
opencode. opencode run --format json --model provider/model.
--continue resumes the last session; --session <id> a specific one.
It is also the universal adapter: an authed opencode reaches anthropic,
openai, google, z.ai and more through its own registry, so a machine with
only opencode still gets every cloud provider. opencode models lists
what its registry offers — the live model list for /models.
z.ai GLM. OpenAI-compatible at
https://api.z.ai/api/coding/paas/v4/chat/completions — the coding
endpoint specifically (the general /paas/v4 endpoint does not draw from
Coding Plan quota). Authorization: Bearer <key>; glm-5.x models;
thinking/reasoning_effort fields are passed through when configured.
Local (ollama / lmstudio / llama.cpp). POST /v1/chat/completions
on the probed port. Probe = GET /api/tags (ollama) or /v1/models with
a 200ms connect timeout; the model list is live — /models shows what is
actually pulled, and a missing model row tells you ollama pull <name>.
agy (Antigravity). Listed so probe can report it; marked
experimental. No documented non-interactive prompt interface exists yet —
when one lands it joins the cli transport with a two-line spec change.
deliberately absent: devin, fusion, agent harnesses. Those create
work sessions, not conversations — they belong to do:'s world. The
registry's cli spec could carry one tomorrow without a Launcher change,
but shipping it as a chat provider would set the wrong expectation about
what Enter does.
/models resolves per provider:
| provider | source |
|---|---|
opencode |
opencode models (live) |
ollama, lmstudio, custom, openai |
GET {endpoint}/models or /api/tags (live) |
claude, codex, gemini, zai, anthropic-api |
shipped defaults + free-form /model <anything> |
| any | ask.models.<provider> in oxy.json overrides the shipped list |
Free-form is always allowed — a model the CLI accepts is valid even if the shipped list predates it.
~/.local/state/omarchy/oxy-ask.json — one JSON document, owned and
rewritten by oxy-ask alone:
{
"v": 1,
"provider": "claude",
"model": "sonnet",
"endpoint": null,
"handle": "a1b2c3d4-…", // cli-kind session id; null for api kind
"messages": [ // api-kind context; cli kind keeps [] — its
{"role": "system", "content": "…"}, // handle carries the context
{"role": "user", "content": "…"},
{"role": "assistant", "content": "…"}
],
"turns": [ // display copy — what the card renders
{"q": "…", "a": "…", "ms": 4200, "provider": "claude"}
],
"created": 1758…,
"touched": 1758…
}Rules oxy-ask enforces:
- TTL:
touchedolder than 6h → the session is gone. Next ask starts fresh. Matches thedo:philosophy — nothing yesterday. - Caps:
messages[]≤ 24 entries and ≤ 32KB total — oldest user/assistant pairs drop first,systemsurvives. Bounds tokens, memory, and cost with one rule. turns[]caps at 20 — display history, not context.- Corrupt file → treated as absent, renamed to
.brokenfor forensics, never crashes a send. - Provider switch mid-session →
turns[]is kept (the card's history is still honest) buthandle/messages[]reset — the new provider never inherits context it cannot read. - SIGTERM mid-stream → the partial answer is committed as the turn's
answer with a
stoppedflag — an interrupted answer is still something said, and the card shows it.
~/.config/omarchy/oxy.json, merged over defaults by Settings.merge:
{
"ask": {
"provider": "", // "" = autodetect; else a registry id
"endpoint": null, // custom OpenAI-compat URL — implies provider "custom"
"model": null, // override per-provider default
"key": "env:OPENAI_API_KEY", // "env:NAME" (recommended) or a literal
"system": "Answer briefly; this renders in a launcher card, not a terminal.",
"maxTokens": 800,
"temperature": 0.4,
"markdown": true, // post-stream markdown render
"ttlHours": 6,
"priority": [], // autodetect order override, e.g. ["ollama","claude"]
"providers": { // per-provider overrides
"zai": { "key": "env:ZAI_API_KEY", "endpoint": null },
"claude":{ "model": "opus" }
},
"models": { // model-list overrides
"claude": ["sonnet", "opus", "haiku"],
"zai": ["glm-5.2", "glm-4.6"]
}
}
}The existing askProviders CLI list stays untouched — it becomes the
fallback tier when no registry provider is live (single-shot, as
today). askProviders entries are commands; the registry is
transports — they coexist because they answer different needs.
oxy-ask resolves a provider when the session has none, in this order
(ask.priority reorders; first live wins):
ask.provider/ask.endpointexplicitly configured- authed subscription CLIs:
claude→codex→gemini→opencode - key'd APIs:
zai→openai→anthropic-api - local:
ollama→lmstudio - none → error card (see §11)
Subscription CLIs before key'd APIs before local: the user asked for "activated subscriptions first, local fallback" — and a logged-in CLI is the strongest signal of intent there is.
"ask" and "chat" join the hardcoded knownKeywords list in
Launcher.qml — no extension JSON, no provider machinery, no search
command. ask: parses to query.scope === "ask"; chat: aliases to the
same path. Query.parse already treats word: as a scope only when the
word is registered, so this is the entire cost of the feature at
keystroke time: two strings in a list.
The Enter handler (the Qt.Key_Return branch of Keys.onPressed)
gains one check before activateSelected():
if (query.scope is ask|chat && query.text !== ""):
if streaming → stop (partial kept) — Enter means "that's enough"
else → ask(query.text); setInput(scope + ":") — box resets, stays in scope
Ctrl+Enter stays exactly as today — root.ask(input.text.trim()) — but
now writes into the same session rather than a stateless call. Enter on
an empty ask: does nothing. Enter while streaming is stop, never
queue — a queued send you can't see is a surprise; stop is obvious.
Input beginning / inside the ask scope is a command, not a turn —
oxy-ask interprets it and prints card text; the LLM never sees it:
| input | effect |
|---|---|
/providers |
list: ● claude — sonnet (live) ○ codex — run: codex auth · gemini — not installed |
/provider <id> |
switch provider; resets handle/messages[], keeps turns[] |
/models |
live list for the active provider (or shipped defaults) |
/model <name> |
switch model — free-form allowed |
/new |
wipe the session; card shows "new conversation" |
/system <text> |
replace the system prompt for this session |
/export |
write the transcript to ~/notes/ or stdout — TBD at build |
/help |
the table above, as card text |
/… typed when no provider is live still works — commands need no
backend.
┌──────────────────────────────────────────────────────┐
│ claude · sonnet · subscription 2 turns ⌄ │
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
│ you: why is my battery draining │
│ ◆ check powerprofiles… · 6 more · 4.2s (collapsed)
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │
│ you: and which process is eating cpu │
│ ▌Run `top -o %CPU` — the first column sorts by…▌caret │
│ │
│ Enter send · Esc stop · /provider /model /new │
└──────────────────────────────────────────────────────┘
- Turns render from
answerTurns, aListModel— append-only, so a finished delegate is never re-instantiated or re-laid-out. - Collapsed by default: your line + the answer's first line + a
· N more · 4.2ssummary. Expand-on-click is a v2 nicety, not v1. - Live turn =
answerQuestion+answerDone + answerTail+ caret — the existing streamed-answer rendering, unchanged. - Header:
provider · model · source(subscription/api key/local) + turn count — answers "which brain is this" at a glance. - Footer: the key hints, already the launcher's pattern.
- Auto-scroll: the
FlickablefollowscontentHeightwhile streaming unless the user has scrolled up (stick-to-bottom rule — scroll up = reading, don't steal the view).
| key | in ask: / answerMode |
|---|---|
Enter |
send · while streaming: stop |
Ctrl+Enter |
send (same session) from anywhere |
Esc |
stop stream → leave answerMode → clear box → close (existing 4-stage unwind, unchanged) |
Ctrl+C |
copy answerDone+answerTail (or selected text — editor's own copy wins, as everywhere) |
Ctrl+K |
action panel (unchanged) |
typing / |
commands, not turns |
oxy-ask ask # send $OXY_ASK_PROMPT as a turn; stream framed stdout
oxy-ask cmd "<line>" # slash commands (/provider, /models, /new, …)
oxy-ask probe # JSON: per-provider {id,name,kind,status,models,active}
oxy-ask history # JSONL of turns[] — card replay on reopen
oxy-ask new # delete session
oxy-ask status # one line: "claude · sonnet · 2 turns · 14m old"
Env (set by the launcher's settingsPrefix from oxy.json — never argv):
| var | content |
|---|---|
OXY_ASK_PROMPT |
the turn's text — argv stays clean of user text |
OXY_ASK_PROVIDER / OXY_ASK_MODEL / OXY_ASK_ENDPOINT |
config passthrough |
OXY_ASK_KEY |
resolved key (or env:NAME reference resolved by oxy-ask) |
OXY_ASK_SYSTEM / OXY_ASK_MAXTOK / OXY_ASK_TEMP |
generation params |
OXY_ASK_PROMPT via env rather than argv: /proc/<pid>/environ is
owner-readable; argv is world-readable. The prompt never appears in
ps. (Same reason oxy-agent feeds prompts on stdin — stdin is
unavailable to a fire-and-forget Process, so env is the next-best
channel.)
PROVIDERS = {
"claude": CliSpec(bin="claude", args=..., resume=..., stream="ndjson",
auth_probe=..., models=[...]),
"codex": CliSpec(...),
"gemini": CliSpec(...),
"opencode": CliSpec(...),
"zai": ApiSpec(endpoint=..., key_env="ZAI_API_KEY", shape="openai"),
"openai": ApiSpec(shape="openai", key_env="OPENAI_API_KEY"),
"anthropic-api": ApiSpec(endpoint=..., key_env="ANTHROPIC_API_KEY",
shape="anthropic"),
"ollama": ApiSpec(endpoint="http://localhost:11434/v1/chat/completions",
probe="/api/tags", shape="openai"),
...
}
ApiSpec.send(messages)— urllib POST, SSE line loop, yields deltas.CliSpec.send(prompt, handle)— builds argv with the provider's resume flag, spawns, parses its event format, yields deltas, returns the new handle.- One
stream(deltas)wrapper writes the framing (complete lines + 120ms NUL-tail) — all providers get identical wire behavior for free. - A provider entry is ~30 lines. Adding one never touches QML.
oxy-ask probe runs once per ask: open (never per keystroke — the
launcher caches it for the session):
cliproviders:command -v(free) + a cheap auth probe where one exists (claude auth status-style; otherwise presence-only, and a failed send marks it unauthed at use-time).apiproviders: key present? →live; elseneeds-key.local: HTTP probe, 200ms connect timeout.- Probes run in parallel on threads (all I/O-bound, ~200ms total).
- Output feeds
/providersand the card header; theactiveprovider is the autodetect winner.
| resource | budget | mechanism |
|---|---|---|
| keystroke cost | 0 processes | scope in knownKeywords; no search command |
| per turn | 1 process, 1 conn | oxy-ask (~40MB transient RSS, exits at stream end) |
| stream redraws | ≤ 8/sec | 120ms coalesce timer, tail-replace framing |
| row model | 0 rebuilds | answer is a string property, never a row |
| markdown parses | 1 per turn | PlainText while streaming → MarkdownText swap after |
| turn delegates | +1 per turn | append-only ListModel; collapsed, fixed height |
| session memory | ≤ 32KB messages, 20 turns | pair-drop cap in oxy-ask |
| answer buffer | ≤ 64KB | answerDone truncates oldest lines past cap |
| idle cost | 0 | no daemon, no socket, no timers armed |
| probe | ~200ms, once per ask: open | parallel threads, cached |
The one number worth defending: the row model is never involved.
putRaw/rebuild/measureChips — the machinery every other answer
pays — is bypassed entirely. The answer view binds to two string
properties and one ListModel; a 60-second stream costs ~480 text layouts
and nothing else.
| failure | card shows |
|---|---|
| no provider live | "No model configured. Live options: ollama (not running), claude (run claude auth login), or set ask.endpoint/OPENAI_API_KEY in oxy.json." |
| provider present, unauthed | "claude is installed but not signed in — run claude auth login, then /provider claude." |
| endpoint unreachable | "could not reach localhost:11434 — start ollama or /provider <other>" |
| API error (429/500/auth) | status + first 200 chars of body — the API's own words |
| mid-stream disconnect | partial answer kept + ⚠ stopped mid-answer |
| SIGTERM (Esc/Enter) | partial committed as the turn, flagged stopped |
| CLI flag drift (old version, unknown resume flag) | degrade to single-turn + warn once in card |
| corrupt session file | fresh session + file moved to .broken |
oxy-ask missing |
"oxy-ask not found — reinstall oxy" |
| session provider gone (uninstalled since) | re-autodetect, note the switch |
Rule: an error is a row of text, never a silence and never a spinner.
- Prompt transport:
OXY_ASK_PROMPTenv — owner-only in/proc, never argv. CLI providers get the prompt on stdin where they accept it (gemini; claude-ppositional is the documented form — noted if argv is unavoidable). - Keys:
env:NAMEindirection is the documented form; literals work but the docs say why not. Keys never appear in logs (log()clips, andoxy-asknever prints headers). - Local-first privacy note:
ollama/lmstudiomeans the transcript never leaves the machine — stated plainly in README so the choice is informed, not assumed. - Session file:
0600, lives under$XDG_STATE_HOME— it's a transcript; treat it like shell history. - No new network surface:
oxy-askmakes outbound requests only; it binds nothing.
The harnesses below already exist: tests/behavior.test.sh runs real
scripts against sandboxed fixtures (fake nmcli, temp git repos, fakebin
stubs) and tests/cases.py runs the shipped .cases.json files under a
throwaway HOME — ask.cases.json slots into both without new machinery.
oxy-ask (the logic core — where the bugs will live):
- Stub SSE server (python
http.server, emits canneddata:frames incl. multi-line deltas and[DONE]) → assert framed output shape, tail-replace cadence, full text, messages[] append. - Anthropic shape stub →
content_block_deltaextraction. - Fake CLI bins — a
claudestub emitting cannedstream-jsonNDJSON (deltas + result withsession_id) → assert--resumeused on turn 2 with the captured id. Same pattern for codex JSONL and a plain-stream gemini stub. - Session: TTL expiry, 24-msg cap drops oldest pair + keeps system, corrupt file recovery, provider-switch reset semantics.
- Slash commands:
/providers//model//newoutput and state effects without a backend. - SIGTERM: partial committed +
stoppedflag. - Framing: NUL-tail lines decode correctly; no stray
\x00inanswerDone.
Launcher side:
tests/logic.test.mjs:Query.parse("ask: hello")→ scope"ask";chat:aliases;ask:unregistered when keyword absent (guards the registration).- Manual matrix: real ollama, a key'd endpoint, one CLI — send, follow-up,
/provider,/model,/new, Esc-mid-stream, close+reopen (history replay).
Each phase is independently useful and independently verified.
Phase 1 — bin/oxy-ask core. ApiSpec transport (openai + anthropic
shapes), session file, framing writer, autodetect, probe, slash
commands. Verified against the stub SSE server + live ollama — no QML
touched yet.
Phase 2 — CLI transports. CliSpec + claude/codex/gemini/opencode
entries with real flags verified against installed binaries (stubs where
not installed). Graceful degrade on flag drift.
Phase 3 — Launcher wiring. ask/chat in knownKeywords; Enter
intercept; OXY_ASK_PROMPT env in shellArgv; answerTurns ListModel;
120ms flush + NUL-tail; Ctrl+C → answer; /-input routing to oxy-ask cmd.
Phase 4 — Card + polish. ResultAnswer turns view + header/footer +
stick-to-bottom; probe on ask: open; markdown post-swap; unit.toml
touches; README section; tests.
Phase 5 — stretch (not v1): /export transcript, expandable turns,
Ctrl+M model cycle, agy once a prompt interface exists, agent-harness
providers as cli specs if ever wanted.
| file | Δ | what |
|---|---|---|
bin/oxy-ask |
+~450 | registry, transports, session, probe, slash, framing |
plugin/Launcher.qml |
+~130 | scope intercept, answerTurns, flush/NUL-tail, env prompt, / routing, Ctrl+C→answer |
plugin/ResultAnswer.qml |
+~110 | turns ListView, header, footer hints, markdown swap, stick-to-bottom |
plugin/Settings.js |
+~25 | ask defaults, ask.priority, models/providers maps |
tests/logic.test.mjs |
+~30 | scope parse, alias, framing decode |
tests/fixtures/ |
+~120 | stub SSE server + fake CLI bins |
unit.toml |
+1 | ~/.local/state/omarchy/oxy-ask.json in touches |
README.md |
+~50 | ask: section, provider table, config keys |
docs/LLM-INTEGRATION.md |
this file | the plan |
- No tools. The model never gets function-calling, file access, or a
shell — that is
do:'s job, and mixing them is how a chat becomes a footgun. - No daemon. Revisited and rejected in §3; if a future need
(background streaming while closed) appears, the socket pattern in
oxy-agentis the template, not a redesign. - No per-keystroke work, ever. Not a hint row, not a
whenprobe.ask:costs exactly two strings inknownKeywordsuntil Enter. - No auth implementation. Each provider's own login is the
activation;
probereports status, never credentials. - No images/tools/multimodal in v1 — the wire supports it later; the card doesn't need it now.
| risk | mitigation |
|---|---|
| CLI flag drift (resume flags differ across versions) | isolate per-provider in CliSpec; on send failure naming a flag → degrade to single-turn + warn; never crash |
agy interface unknown |
probe-only entry; documented as experimental; two-line spec when it lands |
| markdown flicker mid-stream | PlainText-during-stream chosen; markdown: false escape hatch |
| very long answers | 64KB answer cap; messages[] 32KB cap; turn collapse keeps the card short |
| user expects chat to act ("delete X") | footer + docs point at do: — honest boundary |
| session file as plaintext transcript | 0600, state dir, TTL 6h, /new wipes — same posture as shell history |
Status: planned, not yet implemented. Phase order and the per-provider spec details in §5.3 are the contract the implementation is checked against.