qs-ask: log outcomes to journal; launcher: spinner while Claude answers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-08-02 11:27:08 +01:00
parent ba9558ba30
commit 748a345ea9
2 changed files with 57 additions and 13 deletions

View file

@ -99,18 +99,32 @@
# call fails or the model doesn't know — silence is what the launcher # call fails or the model doesn't know — silence is what the launcher
# reads as "keep the Wikipedia answer". Every reason for that silence goes # reads as "keep the Wikipedia answer". Every reason for that silence goes
# to STDERR instead, which the launcher discards and a human running # to STDERR instead, which the launcher discards and a human running
# `qs-ask` by hand can read. # `qs-ask` by hand can read — and to the journal, since over ssh nobody
# ever sees that STDERR. `journalctl -t qs-ask` is the only way to tell a
# blank card caused by UNKNOWN from one caused by a failed call.
(pkgs.writeShellScriptBin "qs-ask" '' (pkgs.writeShellScriptBin "qs-ask" ''
# Every line carries this script's pid, because the launcher fires a
# fresh call per debounce and concurrent runs otherwise interleave with
# no way to pair a question with its outcome. The pid goes in the
# message text rather than through logger's --id: --id sets SYSLOG_PID,
# which journalctl does not display — it shows the pid of the logger
# process itself, a different number on every line.
log() {
printf 'qs-ask: %s\n' "$*" >&2
${pkgs.util-linux}/bin/logger -t qs-ask -- "[$$] $*"
}
q=$(${pkgs.coreutils}/bin/cat) q=$(${pkgs.coreutils}/bin/cat)
[ -z "$q" ] && { echo "qs-ask: no question on stdin" >&2; exit 0; } [ -z "$q" ] && { log "no question on stdin"; exit 0; }
log "ask: $q"
# Claude Code derives its project state (transcripts, auto-memory) from # Claude Code derives its project state (transcripts, auto-memory) from
# the working directory, so run from a dedicated empty one. Combined with # the working directory, so run from a dedicated empty one. Combined with
# --no-session-persistence and --safe-mode, a launcher question leaves no # --no-session-persistence and --safe-mode, a launcher question leaves no
# trace in the real projects' history or memory. # trace in the real projects' history or memory.
work="$HOME/.cache/qs-ask" work="$HOME/.cache/qs-ask"
${pkgs.coreutils}/bin/mkdir -p "$work" || { echo "qs-ask: cannot create $work" >&2; exit 0; } ${pkgs.coreutils}/bin/mkdir -p "$work" || { log "cannot create $work"; exit 0; }
cd "$work" || { echo "qs-ask: cannot enter $work" >&2; exit 0; } cd "$work" || { log "cannot enter $work"; exit 0; }
# MAX_THINKING_TOKENS=0 is the single biggest win here, worth ~2.4x. # MAX_THINKING_TOKENS=0 is the single biggest win here, worth ~2.4x.
# Measured: Haiku defaults to extended thinking, and it spent 225 output # Measured: Haiku defaults to extended thinking, and it spent 225 output
@ -142,14 +156,15 @@
--name qs-ask \ --name qs-ask \
--system-prompt 'Answer the question in one or two short sentences, under 240 characters. Lead with the specific fact asked for, including units. The query is often bare keywords rather than a question ("shadow hunter knife classic wow"); treat that as a request for the key facts about that thing, not as unanswerable. No preamble, no caveats, no markdown, no follow-up offers. Reply with exactly UNKNOWN only when you genuinely do not know, or the answer depends on live data you do not have.' \ --system-prompt 'Answer the question in one or two short sentences, under 240 characters. Lead with the specific fact asked for, including units. The query is often bare keywords rather than a question ("shadow hunter knife classic wow"); treat that as a request for the key facts about that thing, not as unanswerable. No preamble, no caveats, no markdown, no follow-up offers. Reply with exactly UNKNOWN only when you genuinely do not know, or the answer depends on live data you do not have.' \
2>/dev/null) \ 2>/dev/null) \
|| { echo "qs-ask: claude exited non-zero (auth expired? run 'claude' once to log in)" >&2; exit 0; } || { log "claude exited non-zero (auth expired? run 'claude' once to log in)"; exit 0; }
ans=$(printf '%s' "$ans" | ${pkgs.coreutils}/bin/tr -d '\r' | ${pkgs.gnused}/bin/sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') ans=$(printf '%s' "$ans" | ${pkgs.coreutils}/bin/tr -d '\r' | ${pkgs.gnused}/bin/sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
case "$ans" in case "$ans" in
"") echo "qs-ask: empty answer" >&2; exit 0 ;; "") log "empty answer"; exit 0 ;;
UNKNOWN*) echo "qs-ask: model replied UNKNOWN" >&2; exit 0 ;; UNKNOWN*) log "model replied UNKNOWN"; exit 0 ;;
esac esac
log "ok: $ans"
printf '%s' "$ans" printf '%s' "$ans"
'') '')
]; ];

View file

@ -2415,7 +2415,10 @@ in
height: answerCol.height + 28 height: answerCol.height + 28
radius: Theme.radiusCard radius: Theme.radiusCard
color: Theme.cardBg color: Theme.cardBg
visible: launcherPanel.answerFact !== "" // Also up while Claude's call is out with nothing
// on screen yet, so the spinner has somewhere to
// live during the first second.
visible: launcherPanel.answerFact !== "" || askProc.running
Column { Column {
id: answerCol id: answerCol
@ -2451,6 +2454,7 @@ in
// answer already came from it. // answer already came from it.
SText { SText {
width: parent.width width: parent.width
visible: launcherPanel.answerFact !== ""
text: launcherPanel.answerFact text: launcherPanel.answerFact
font.pixelSize: 13 font.pixelSize: 13
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
@ -2466,11 +2470,36 @@ in
} }
// Always say who answered the two sources // Always say who answered the two sources
// carry very different trust. // carry very different trust. The spinner
SText { // runs for as long as Claude's ~6s call is
text: launcherPanel.answerSource // out, which is the only cue that a
font.pixelSize: 10 // Wikipedia answer already on screen is
color: Theme.base03 // still provisional and that a card that
// never changes was Claude declining, not
// Claude being slow.
Row {
spacing: 5
SIcon {
anchors.verticalCenter: parent.verticalCenter
visible: askProc.running
text: "loader-circle"
font.pixelSize: 11
color: Theme.base03
NumberAnimation on rotation {
from: 0; to: 360
duration: 900
loops: Animation.Infinite
running: askProc.running
}
}
SText {
anchors.verticalCenter: parent.verticalCenter
text: askProc.running
? (launcherPanel.answerSource === "" ? "Claude" : launcherPanel.answerSource + " · Claude")
: launcherPanel.answerSource
font.pixelSize: 10
color: Theme.base03
}
} }
} }