Skip to content

Commit 4b65ef2

Browse files
StuBehanclaude
andauthored
fix: security & correctness hardening from deep-dive audit (#98)
* fix(security): harden notify.sh against injection and config code-exec - Parse ~/.stack-nudge/config as allowlisted STACKNUDGE_* KEY=VALUE data instead of `source`-ing it, so a write to that file can no longer run arbitrary code on every hook event. - Pass the cwd basename (project name) and tty path into osascript via the environment and read them back with `system attribute`, instead of interpolating them into the AppleScript text — closes AppleScript/shell injection via a maliciously named project directory or tty path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: serialise CodexQuotaProbe reads to avoid cacheKey/cached data race read() mutated the cacheKey/cached stored properties from a global concurrent queue, so overlapping quota polls could race and corrupt the cache. Route read() through a private serial queue so those properties are only ever touched on one queue. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: harden notify.sh phrase rendering and permission FIFO - Render voice phrases via explicit %s substitution instead of using the phrase as a printf format string, so stray % tokens (especially in user-supplied phrases.user.json entries) can't corrupt the spoken output. - Create the permission FIFO inside a private mktemp -d directory (0700, CSPRNG-named) rather than a 16-bit $RANDOM /tmp path a local attacker could guess and race; clean up the directory in the EXIT trap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: validate prebuilt bundle and escape plist XML in install.sh - Verify build/StackNudge.app carries an executable before removing the installed copy, so a corrupt artifact can't leave the user with no app. - XML-escape labels, paths and program args written into the launchd plist so an install dir containing & < > can't produce a plist launchd rejects. - Document STACKNUDGE_DEBUG in notify.conf.example. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: correctness fixes in dedup, deny-FIFO, device-flow and antigravity - EventStore.append: abs() the dedup timestamp delta so an out-of-order event isn't silently dropped as a false duplicate. - actOnSelected: write the FIFO on Deny too (not only Allow), so a Deny no longer leaves the agent's hook blocked until its 550s timeout. - pollGithubSignIn: enforce the device code's expiry (RFC 8628) instead of polling forever, and back off by at least 5s on slow_down. - AntigravityLocalServer: raise the semaphore backstop above URLSession's resource timeout so the request completion releases the wait. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: require update checksum and surface silent API/parse failures - Updater: refuse to install when a release has no .sha256 sidecar (every release publishes one) instead of silently skipping checksum verification. - GitHubAPI.parse: detect the GraphQL {errors:[...]} envelope on HTTP 200 and log it, rather than treating an errored response as "no PR found". - Phrases.defaults: log when the phrase-loading subprocess fails to launch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: surface silent listener, quota and credential failures in the UI Addresses the three remaining high-severity deep-dive findings — each a failure that happened with no user-visible signal: - Socket-bind failure: startListener records nav.listenerError (cleared on a successful bind) and the Events tab shows an orange banner, instead of the panel silently receiving no events with only a stderr line in the log. - Unofficial Anthropic quota endpoint: QuotaProbe now distinguishes "had a token but the request/parse failed" from "no token", so the Usage tab shows "quota unavailable" instead of "Loading usage…" forever when the endpoint shape changes. - Plaintext OAuth token: keep preferring ~/.claude/.credentials.json (it's intentional — avoids Claude Code's periodic Keychain re-prompt) but warn in Settings -> Usage when that path is active, since any same-user process can read the file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5bbca35 commit 4b65ef2

13 files changed

Lines changed: 237 additions & 56 deletions

install.sh

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ if [[ "$(uname -s)" == "Darwin" ]]; then
4949
exit 1
5050
fi
5151
fi
52+
# Don't destroy the installed copy until we've confirmed the new bundle
53+
# actually carries an executable — a corrupt or incomplete artifact would
54+
# otherwise leave the user with no app installed at all.
55+
if ! ls "$PREBUILT_APP"/Contents/MacOS/* >/dev/null 2>&1; then
56+
echo "$PREBUILT_APP has no executable in Contents/MacOS — refusing to replace the installed app."
57+
exit 1
58+
fi
5259
rm -rf "$HOME/Applications/StackNudge.app"
5360
rm -rf "$HOME/Applications/stack-nudge-panel.app" # clean up old panel binary
5461
cp -r "$PREBUILT_APP" "$HOME/Applications/StackNudge.app"
@@ -116,10 +123,22 @@ register_launchd_agent() {
116123
shift 3
117124
local plist_path="$HOME/Library/LaunchAgents/${label}.plist"
118125

126+
# Escape XML metacharacters before interpolating any value into the plist —
127+
# a program arg or path containing & < > (e.g. an install dir whose name
128+
# contains them) would otherwise produce a malformed plist launchd rejects.
129+
xml_escape() {
130+
local s="$1"
131+
s="${s//&/&amp;}"; s="${s//</&lt;}"; s="${s//>/&gt;}"
132+
printf '%s' "$s"
133+
}
134+
119135
local program_xml=""
120136
for arg in "$@"; do
121-
program_xml+=" <string>${arg}</string>"$'\n'
137+
program_xml+=" <string>$(xml_escape "$arg")</string>"$'\n'
122138
done
139+
local label_xml log_xml
140+
label_xml=$(xml_escape "$label")
141+
log_xml=$(xml_escape "$log_path")
123142

124143
local keep_alive_xml
125144
case "$keep_alive_mode" in
@@ -134,7 +153,7 @@ register_launchd_agent() {
134153
<plist version="1.0">
135154
<dict>
136155
<key>Label</key>
137-
<string>${label}</string>
156+
<string>${label_xml}</string>
138157
<key>ProgramArguments</key>
139158
<array>
140159
${program_xml} </array>
@@ -143,9 +162,9 @@ ${program_xml} </array>
143162
<key>KeepAlive</key>
144163
${keep_alive_xml}
145164
<key>StandardOutPath</key>
146-
<string>${log_path}</string>
165+
<string>${log_xml}</string>
147166
<key>StandardErrorPath</key>
148-
<string>${log_path}</string>
167+
<string>${log_xml}</string>
149168
</dict>
150169
</plist>
151170
PLIST

notify.conf.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,8 @@
4444
# always notify regardless of focus.
4545
# Default: true
4646
#STACKNUDGE_MUTE_WHEN_FOCUSED=false
47+
48+
# Log a debug line (to the launchd log) explaining voice decisions — useful
49+
# when a notification played silently and you want to know why.
50+
# Default: false
51+
#STACKNUDGE_DEBUG=true

notify.sh

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,27 @@ AGENT="${1:-agent}"
99
EVENT="${2:-stop}"
1010
OS="$(uname -s 2>/dev/null || echo Windows)"
1111

12-
# Load user config (overrides defaults below).
12+
# Load user config (overrides defaults below). Parse as KEY=VALUE data only —
13+
# never `source` it, so anything able to write to this file (a compromised
14+
# postinstall, a stray tool) can't get arbitrary code to run on every hook
15+
# event. Only STACKNUDGE_* keys are recognised; anything else is ignored.
1316
# Copy notify.conf.example to ~/.stack-nudge/config to customise.
14-
[[ -f "${HOME}/.stack-nudge/config" ]] && source "${HOME}/.stack-nudge/config"
17+
load_stacknudge_config() {
18+
local config="${HOME}/.stack-nudge/config"
19+
[[ -f "$config" ]] || return 0
20+
local line key value
21+
while IFS= read -r line || [[ -n "$line" ]]; do
22+
[[ "$line" =~ ^[[:space:]]*(export[[:space:]]+)?(STACKNUDGE_[A-Z0-9_]+)=(.*)$ ]] || continue
23+
key="${BASH_REMATCH[2]}"
24+
value="${BASH_REMATCH[3]}"
25+
# Strip one layer of matching surrounding quotes, if present.
26+
if [[ "$value" =~ ^\"(.*)\"$ ]] || [[ "$value" =~ ^\'(.*)\'$ ]]; then
27+
value="${BASH_REMATCH[1]}"
28+
fi
29+
printf -v "$key" '%s' "$value"
30+
done < "$config"
31+
}
32+
load_stacknudge_config
1533

1634
# Read JSON piped from Claude Code hooks (contains transcript_path for Stop events).
1735
# Skip if stdin is a terminal (manual invocation).
@@ -221,8 +239,11 @@ voice_phrase_for() {
221239
fi
222240

223241
local template="${templates[$((RANDOM % ${#templates[@]}))]}"
224-
# shellcheck disable=SC2059
225-
printf "$template" "$repo"
242+
# Substitute the repo name into the %s placeholder ourselves rather than
243+
# letting the phrase be a printf format string — a phrase (especially a
244+
# user-supplied one from phrases.user.json) with stray % tokens would
245+
# otherwise corrupt or truncate the spoken output.
246+
printf '%s' "${template//%s/$repo}"
226247
}
227248

228249
PANEL_SOCK="${HOME}/.stack-nudge/panel.sock"
@@ -305,12 +326,17 @@ detect_iterm_tab_name() {
305326
tty_path=$(tty 2>/dev/null) || return
306327
[[ -z "$tty_path" || "$tty_path" == "not a tty" ]] && return
307328

308-
ITERM_TAB_NAME=$(osascript <<APPLESCRIPT 2>/dev/null || true
329+
# Pass the tty path through the environment and read it back with
330+
# `system attribute` inside a quoted heredoc, rather than letting bash
331+
# interpolate it into the script text — keeps an unusual /dev path (or a
332+
# symlinked pseudo-tty name) from breaking out of the AppleScript string.
333+
ITERM_TAB_NAME=$(STACKNUDGE_TTY="$tty_path" osascript <<'APPLESCRIPT' 2>/dev/null || true
334+
set ttyPath to system attribute "STACKNUDGE_TTY"
309335
tell application "iTerm2"
310336
repeat with w in windows
311337
repeat with t in tabs of w
312338
repeat with s in sessions of t
313-
if (tty of s) is equal to "$tty_path" then
339+
if (tty of s) is equal to ttyPath then
314340
return name of s
315341
end if
316342
end repeat
@@ -503,11 +529,17 @@ notify_macos() {
503529
local project_name
504530
project_name=$(basename "$PWD")
505531
if [[ -n "$process_name" ]]; then
506-
win_title=$(osascript \
532+
# Pass the project name through the environment and read it back inside
533+
# AppleScript via `system attribute`, rather than interpolating it into the
534+
# script text — a directory named `foo" & do shell script "…` would
535+
# otherwise close the string and inject AppleScript/shell. process_name is
536+
# safe to interpolate (it comes from the fixed bundle-id allowlist above).
537+
win_title=$(STACKNUDGE_PROJECT_NAME="$project_name" osascript \
538+
-e "set projectName to system attribute \"STACKNUDGE_PROJECT_NAME\"" \
507539
-e "tell application \"System Events\"" \
508540
-e " tell process \"${process_name}\"" \
509541
-e " try" \
510-
-e " get title of first window whose title contains \"${project_name}\"" \
542+
-e " get title of first window whose title contains projectName" \
511543
-e " end try" \
512544
-e " end tell" \
513545
-e "end tell" 2>/dev/null)
@@ -554,10 +586,17 @@ notify_macos() {
554586
# Create a unique FIFO at /tmp for the user's response. Echoes the path.
555587
# Returns empty if mkfifo fails.
556588
create_perm_fifo() {
557-
local fifo
558-
fifo="/tmp/stack-nudge-perm-$$-$(date +%s)-$RANDOM.fifo"
589+
# Place the FIFO inside a private mktemp dir (mode 0700, CSPRNG-named) rather
590+
# than a $RANDOM-suffixed /tmp path — $RANDOM is only 16-bit, so the old name
591+
# was guessable, letting a local attacker pre-create the FIFO or inject a
592+
# decision. The dir is removed alongside the FIFO in wait_for_permission_response.
593+
local dir
594+
dir=$(mktemp -d "${TMPDIR:-/tmp}/stack-nudge-perm.XXXXXXXX" 2>/dev/null) || return
595+
local fifo="$dir/fifo"
559596
if mkfifo -m 0600 "$fifo" 2>/dev/null; then
560597
echo "$fifo"
598+
else
599+
rmdir "$dir" 2>/dev/null
561600
fi
562601
}
563602

@@ -569,7 +608,7 @@ wait_for_permission_response() {
569608
local fifo="$1"
570609
local timeout=550 # Claude Code's hook timeout defaults to 600s — leave buffer
571610

572-
trap 'rm -f "$fifo"' EXIT
611+
trap 'rm -f "$fifo"; rmdir "$(dirname "$fifo")" 2>/dev/null' EXIT
573612

574613
local decision
575614
decision=$(NUDGE_FIFO="$fifo" NUDGE_TIMEOUT="$timeout" python3 - <<'PY' 2>/dev/null

panel/AntigravityLocalServer.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ enum AntigravityLocalServer {
4646
status = (response as? HTTPURLResponse)?.statusCode ?? 0
4747
semaphore.signal()
4848
}.resume()
49-
_ = semaphore.wait(timeout: .now() + 5)
49+
// Backstop above URLSession's own resource timeout (6s) so the request's
50+
// completion handler is what releases the wait — not a shorter deadline
51+
// that returns while the dataTask is still running.
52+
_ = semaphore.wait(timeout: .now() + 8)
5053
return status == 200 ? payload : nil
5154
}
5255

panel/CodexUsage.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,15 @@ final class CodexQuotaProbe {
2929
private var cacheKey: String?
3030
private var cached: CodexQuotaSnapshot?
3131

32+
// Serialises read() so overlapping polls — the 60s/5min timer firing while a
33+
// prior fetch is still doing disk I/O on a large ~/.codex/sessions tree —
34+
// can't race on cacheKey/cached. These are only ever touched on this queue.
35+
private let probeQueue = DispatchQueue(label: "stack-nudge.codex-quota")
36+
3237
// Calls completion on the main queue. File IO runs off-main.
3338
func fetch(completion: @escaping (CodexQuotaSnapshot?) -> Void) {
3439
let dir = sessionsDir
35-
DispatchQueue.global(qos: .utility).async { [weak self] in
40+
probeQueue.async { [weak self] in
3641
let result = self?.read(dir: dir) ?? nil
3742
DispatchQueue.main.async { completion(result) }
3843
}

panel/EventStore.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ final class EventStore: ObservableObject {
167167
// identical one landed within the dedup window.
168168
let dedupWindow: TimeInterval = 2
169169
if events.contains(where: { existing in
170-
event.timestamp.timeIntervalSince(existing.timestamp) < dedupWindow
170+
abs(event.timestamp.timeIntervalSince(existing.timestamp)) < dedupWindow
171171
&& existing.agent == event.agent
172172
&& existing.kind == event.kind
173173
&& existing.message == event.message

panel/GitHubAPI.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,17 @@ enum GitHubAPI {
7171

7272
static func parse(_ json: String) -> PullRequestInfo? {
7373
guard let data = json.data(using: .utf8),
74-
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
75-
let node = firstPRNode(root),
74+
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
75+
else { return nil }
76+
// GraphQL returns HTTP 200 with {"data":null,"errors":[…]} on failures
77+
// (bad token, rate limit, field errors). Surface them instead of
78+
// silently treating the response as "no PR found".
79+
if let errors = root["errors"] as? [[String: Any]], !errors.isEmpty {
80+
let messages = errors.compactMap { $0["message"] as? String }.joined(separator: "; ")
81+
FileHandle.standardError.write(Data("stack-nudge: GitHub GraphQL errors: \(messages)\n".utf8))
82+
return nil
83+
}
84+
guard let node = firstPRNode(root),
7685
let number = node["number"] as? Int,
7786
let url = node["url"] as? String,
7887
let stateRaw = node["state"] as? String,

panel/Panel.swift

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ struct PanelContentView: View {
219219

220220
private var eventsBody: some View {
221221
VStack(alignment: .leading, spacing: 0) {
222+
if let error = nav.listenerError {
223+
listenerErrorBanner(error)
224+
}
222225
if store.events.isEmpty {
223226
emptyState
224227
} else {
@@ -229,6 +232,21 @@ struct PanelContentView: View {
229232
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
230233
}
231234

235+
private func listenerErrorBanner(_ error: String) -> some View {
236+
HStack(alignment: .top, spacing: 8) {
237+
Image(systemName: "exclamationmark.triangle.fill")
238+
.foregroundStyle(.orange)
239+
Text(error)
240+
.font(.caption)
241+
.foregroundStyle(.secondary)
242+
.fixedSize(horizontal: false, vertical: true)
243+
}
244+
.padding(.horizontal, 12)
245+
.padding(.vertical, 8)
246+
.frame(maxWidth: .infinity, alignment: .leading)
247+
.background(Color.orange.opacity(0.12))
248+
}
249+
232250
private var emptyState: some View {
233251
VStack(spacing: 10) {
234252
Image(systemName: "bell.slash")
@@ -1198,10 +1216,15 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
11981216
quotaProbe.fetch { [weak self] snapshot in
11991217
guard let self else { return }
12001218
self.nav.quotaSyncing = false
1201-
guard let snapshot else { return }
1202-
self.nav.quota = snapshot
1203-
self.nav.quotaLastUpdated = Date()
1204-
self.evaluateQuotaThresholds(snapshot)
1219+
self.nav.usingPlaintextCredentials = self.quotaProbe.usingPlaintextCredentials
1220+
if let snapshot {
1221+
self.nav.quota = snapshot
1222+
self.nav.quotaError = nil
1223+
self.nav.quotaLastUpdated = Date()
1224+
self.evaluateQuotaThresholds(snapshot)
1225+
} else if self.quotaProbe.lastProbeFailed {
1226+
self.nav.quotaError = "Quota data unavailable — the usage endpoint may have changed."
1227+
}
12051228
}
12061229
// Codex (ChatGPT-plan) rate limits — read locally from the newest
12071230
// rollout, no network. Independent of the Anthropic probe above so one
@@ -1638,7 +1661,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
16381661
userCode: response.userCode, verificationURI: response.verificationURI)
16391662
self.pollGithubSignIn(clientID: clientID,
16401663
deviceCode: response.deviceCode,
1641-
interval: response.interval)
1664+
interval: response.interval,
1665+
deadline: Date().addingTimeInterval(TimeInterval(response.expiresIn)))
16421666
}
16431667
}
16441668
}
@@ -1647,9 +1671,15 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
16471671

16481672
// One poll per `interval` seconds. Stops if the user cancelled (state left
16491673
// awaitingApproval). On success, stores the token and kicks a PR refresh.
1650-
private func pollGithubSignIn(clientID: String, deviceCode: String, interval: Int) {
1674+
private func pollGithubSignIn(clientID: String, deviceCode: String, interval: Int, deadline: Date) {
16511675
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(max(1, interval))) { [weak self] in
16521676
guard let self, case .awaitingApproval = self.nav.githubSignIn else { return }
1677+
// Enforce the device code's own expiry (RFC 8628 §3.5) rather than
1678+
// polling forever if the endpoint never returns expired_token.
1679+
if Date() >= deadline {
1680+
self.nav.githubSignIn = .failed("Code expired — try again")
1681+
return
1682+
}
16531683
GitHubAuth.poll(clientID: clientID, deviceCode: deviceCode) { result in
16541684
DispatchQueue.main.async {
16551685
guard case .awaitingApproval = self.nav.githubSignIn else { return }
@@ -1660,9 +1690,10 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
16601690
self.nav.githubSignIn = .idle
16611691
self.refreshPullRequests()
16621692
case .pending:
1663-
self.pollGithubSignIn(clientID: clientID, deviceCode: deviceCode, interval: interval)
1693+
self.pollGithubSignIn(clientID: clientID, deviceCode: deviceCode, interval: interval, deadline: deadline)
16641694
case .slowDown(let slower):
1665-
self.pollGithubSignIn(clientID: clientID, deviceCode: deviceCode, interval: slower)
1695+
// RFC 8628: back off by at least 5s; honour a larger server interval.
1696+
self.pollGithubSignIn(clientID: clientID, deviceCode: deviceCode, interval: max(slower, interval + 5), deadline: deadline)
16661697
case .expired:
16671698
self.nav.githubSignIn = .failed("Code expired — try again")
16681699
case .denied:
@@ -2533,10 +2564,12 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
25332564
let sendApproval = approve && event.hasActionButton
25342565

25352566
// FIFO path = the source hook is blocking on a permission decision.
2536-
// Write "allow" to it and we're done — Claude Code skips its UI prompt.
2537-
if sendApproval, let fifo = event.fifoPath {
2567+
// Write the decision (allow or deny) and we're done — the agent consumes
2568+
// it and skips its own UI prompt. Previously only "allow" was written, so
2569+
// a Deny left the hook blocked until its 550s timeout.
2570+
if let fifo = event.fifoPath {
25382571
DispatchQueue.global(qos: .userInitiated).async {
2539-
Self.writeFIFO(fifo, "allow")
2572+
Self.writeFIFO(fifo, approve ? "allow" : "deny")
25402573
}
25412574
return
25422575
}
@@ -2780,9 +2813,13 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
27802813
listener = EventListener(store: store, socketPath: socketPath)
27812814
do {
27822815
try listener?.start()
2816+
nav.listenerError = nil
27832817
} catch {
27842818
FileHandle.standardError.write(Data(
27852819
"stack-nudge-panel: listener failed: \(error)\n".utf8))
2820+
nav.listenerError =
2821+
"Event socket failed to start — agent notifications won't arrive. "
2822+
+ "Restart StackNudge to retry. (\(error))"
27862823
}
27872824
}
27882825
}

panel/PanelNav.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,19 @@ final class PanelNav: ObservableObject {
178178
// True while a probe is in-flight. Set by PanelController around the
179179
// fetch call so the UI can swap the footer status to "Syncing…".
180180
@Published var quotaSyncing: Bool = false
181+
// Set when a probe had a token but the request/parse failed (vs. simply
182+
// having no Claude Code session). Drives the Usage tab's "quota unavailable"
183+
// state so a silently-changed endpoint isn't read as "still loading".
184+
// Cleared on the next successful probe.
185+
@Published var quotaError: String?
186+
// True when the Claude token was read from the plaintext
187+
// ~/.claude/.credentials.json rather than the Keychain — any same-user
188+
// process can read that file. Drives a warning in Settings → Usage.
189+
@Published var usingPlaintextCredentials: Bool = false
190+
// Set when the event socket failed to bind at startup — the panel is then
191+
// deaf to every agent notification. Drives the banner at the top of the
192+
// Events tab so the failure isn't silent. Cleared when the socket binds.
193+
@Published var listenerError: String?
181194
// Per-Claude-session context-window stats. Keyed by Claude's
182195
// session_id UUID (NudgeEvent.claudeSessionID), populated by
183196
// PanelController whenever an event arrives with a transcript_path.

panel/Phrases.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,11 @@ struct PhraseStore {
113113
let pipe = Pipe()
114114
task.standardOutput = pipe
115115
task.standardError = Pipe()
116-
do { try task.run() } catch { return [:] }
116+
do { try task.run() } catch {
117+
FileHandle.standardError.write(Data(
118+
"stack-nudge: failed to load phrase defaults for \(lang): \(error)\n".utf8))
119+
return [:]
120+
}
117121
let data = pipe.fileHandleForReading.readDataToEndOfFile()
118122
task.waitUntilExit()
119123

0 commit comments

Comments
 (0)