Skip to content

fix: 避免已完成的更新检查被 abort 回调覆盖#138

Merged
wxtsky merged 1 commit into
wxtsky:mainfrom
Drswith:fix/update-abort-state
Apr 29, 2026
Merged

fix: 避免已完成的更新检查被 abort 回调覆盖#138
wxtsky merged 1 commit into
wxtsky:mainfrom
Drswith:fix/update-abort-state

Conversation

@Drswith
Copy link
Copy Markdown
Contributor

@Drswith Drswith commented Apr 27, 2026

概要

  • 避免 Sparkle 的 abort 回调覆盖已经完成的更新检查状态。
  • 保留手动检查仍在进行中时的真实失败提示。

Closes #137

验证

  • swift build

@Drswith Drswith changed the title fix: avoid stale update abort errors fix: 避免已完成的更新检查被 abort 回调覆盖 Apr 27, 2026
@wxtsky wxtsky merged commit 02a25ef into wxtsky:main Apr 29, 2026
Uncle-Peke added a commit to Uncle-Peke/CodeIsland that referenced this pull request May 4, 2026
* chore: polish Turkish translation for stylistic consistency (wxtsky#135)

Align 4 post-PR entries with the established style (Title Case for
setting labels, ALL CAPS for action buttons, correct semantics):

- auto_collapse_after_session_jump: lowercase → Title Case
- dismiss: ERTELE (postpone) → YOKSAY (ignore/dismiss)
- buddy_reconnect: sentence case → Title Case
- buddy_enable_bluetooth: sentence case → Title Case

Key parity unchanged (259/259 keys covered).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: prevent Sparkle crash in DEBUG mode when running without bundle ID (wxtsky#133)

Co-authored-by: JZ <jz@JZdeMacBook-Pro.local>

* fix: avoid stale update abort errors (wxtsky#138)

* feat: improve Buddy watch approval previews and alerts (wxtsky#144)

Show the full Bash approval command alongside the human-readable description so pending approvals stay understandable on macOS and on the watch. Preserve pending TraeCLI approvals until the user responds, add watch-side approve/deny and skip controls over BLE, and trigger vibration plus richer attention notifications when approval is required.

* test: align AppStateQuestionFlow assertions with wxtsky#144 toolDescription change

wxtsky#144 changed `HookEvent.toolDescription` for Bash to combine description and
command (so ESP32/watch previews show the full command). Update the existing
two assertions to match the new format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: add ProcessRunner with timeout to harden detectClaudeVersion (wxtsky#139)

Replace the unbounded waitUntilExit() in detectClaudeVersion with a shared
ProcessRunner that enforces a hard deadline (SIGTERM, then SIGKILL after 1s
grace) and drains stdout off the wait path so a full pipe buffer cannot
wedge the child. detectClaudeVersion now uses a 5s timeout — a stuck
`claude --version` used to freeze app launch silently.

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: move launch-time hook installation off the main thread (wxtsky#139)

ConfigInstaller.install() does subprocess version detection plus disk I/O
across multiple CLI configs. Running it inline from
applicationDidFinishLaunching meant a slow CLI binary or network home dir
froze the menu bar / panel until install finished — sometimes silently,
because macOS doesn't pop a "Not Responding" dialog for status items.

Detach it onto a userInitiated Task so app UI comes up immediately while
hooks install in the background. HookServer is still started synchronously
before the detach so the socket is listening by the time settings.json is
rewritten (preserves the ordering noted in the existing comment).

Mark AppDelegate.log nonisolated so the detached task can use it without
violating Swift 6 actor isolation (Logger is Sendable).

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: run periodic hook verifyAndRepair off the main thread (wxtsky#139)

checkAndRepairHooks fires from a 300s timer AND every NSWorkspace
didActivateApplicationNotification (i.e. each time the user app-switches).
verifyAndRepair walks every enabled CLI and rewrites settings.json — on a
network home dir or a slow disk that's enough to stutter the panel.

Keep the 60s debounce on the main actor (cheap), but do the actual repair
on a background task. Repair results only feed a log line so no UI thread
hand-off is needed.

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: cap subprocess waits in TerminalActivator/VisibilityDetector (wxtsky#139)

Both files reimplemented the same Process+Pipe+waitUntilExit pattern with
no timeout. Activate() and the visibility checks fire from the main
thread (panel buttons, shortcut handler, NotchPanelView body, etc.), so a
stuck osascript / tmux / iTerm CLI was free to freeze the UI for as long
as the child took to wake up.

Replace both runProcess implementations with ProcessRunner.run:

- TerminalActivator: 10s cap (matches current osascript-tab-switch
  worst-case behaviour; activateCmux still dispatches off-main on its
  own, the timeout is just a backstop).
- TerminalVisibilityDetector: 5s cap (these are speculative "is the tab
  visible?" probes called frequently from view bodies — fail fast).

Removes ~30 lines of duplicated boilerplate. No behaviour change on the
happy path; on a hang, callers see nil instead of a frozen UI.

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: dispatch Ghostty activation off the main thread (wxtsky#139)

activateGhostty was the last activator that still resolved its
subprocess inputs synchronously on the main thread. It calls
`tmux display-message` to fetch a session/window key (used to match
the right Ghostty terminal in the AppleScript template) and then
hands the script off to runOsaScript. Every other activateXxx already
runs on a background queue (activateITerm/Warp/WezTerm/Kitty/Tmux/IDE
window via runAppleScript or an explicit DispatchQueue.global; cmux
has its own dispatch).

Move the same pattern here: keep the AppKit gating (NSWorkspace
runningApplications + unhide) on the main thread where it has to live,
then dispatch the tmux probe, AppleScript construction, and
osascript invocation onto a userInitiated queue. With this change no
TerminalActivator code path can hold the main thread on a stuck
subprocess, which closes the loop on issue wxtsky#139.

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: register Codex PermissionRequest hook so approvals trigger UI + sound (wxtsky#145)

Codex CLI supports a PermissionRequest hook event (per
developers.openai.com/codex/hooks) that fires when Codex needs user
approval — shell escalation, managed-network access, etc. CodeIsland's
Codex CLIConfig only registered SessionStart/End, UserPromptSubmit,
PreToolUse, PostToolUse, and Stop, so this event was never installed
into Codex's hooks.json.

Result: when Codex hit an approval gate, the panel stayed in "running"
status forever, no approval card opened, and SoundManager wasn't
triggered (handlePermissionRequest is only called from the
PermissionRequest event path).

Add the event with the same shape as Claude's: 24h timeout, blocking
(async=false) so Codex waits for the user's allow/deny decision.

Existing users need to hit Settings → Reinstall Hooks once to rewrite
~/.codex/hooks.json with the new entry; new installs pick it up
automatically.

Refs wxtsky#145.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: setting to disable auto-expand on agent completion (wxtsky#146)

Adds Settings → Behavior → "Auto-Expand Panel on Agent Completion"
(default On — preserves current behavior). When off, CodeIsland stays
in its compact state when agents/subagents finish; status indicators,
the queue, and manual interactions are unaffected.

Implementation:
- New SettingsKey/SettingsDefaults entry registered with default true
  so existing users keep the current behavior across upgrades.
- AppState.enqueueCompletion early-returns when the toggle is off.
  This is the single chokepoint — both Stop events from the reducer
  and any other paths into completion display funnel through here, so
  one guard covers all entry points.
- BehaviorToggleRow added next to the existing auto-collapse toggle,
  reusing the .smartSuppress animation icon (semantically the closest
  "suppress auto-expand" preview we already have).
- Translations added for en / zh / ja / ko / tr.

Refs wxtsky#146.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: prefer opencode.jsonc over opencode.json when registering plugin (wxtsky#132)

OpenCode's troubleshooting guide recommends opencode.jsonc (with-comments
JSON) as the canonical config filename. Before this change, CodeIsland
only ever read/wrote ~/.config/opencode/opencode.json, so users on
opencode.jsonc would see CodeIsland resurrect a sibling opencode.json
on every install / repair pass.

Pick the target by precedence at install time:
1. opencode.jsonc — write here when it already exists (OpenCode-recommended)
2. opencode.json   — fallback for existing setups, also the default for fresh installs
3. config.json     — legacy, still cleaned up after migration

uninstall and isOpencodePluginInstalled now scan all three. The existing
mergeOpencodePluginRef path already runs through stripJSONComments, so
.jsonc input parses correctly without changes.

Refs wxtsky#132.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: surface subagent count + agent type tooltip on session card (wxtsky#141)

Subagents already rendered as 8px MiniAgentIcons under the mascot but
were visually identical and unlabelled — users couldn't tell at a glance
which sessions had spawned background work, or what each subagent was
actually doing.

Two additions, no layout changes:

1. Each MiniAgentIcon now carries a .help(...) tooltip that surfaces
   the agent type and current tool (e.g. "Explore — Read src/main.swift",
   or just "general-purpose" when no tool is active).
2. A new SessionTag "+N Sub" (purple) appears in the session header tag
   row alongside @Remote / INT / YOLO when session.subagents is
   non-empty — gives the at-a-glance count the issue asked for without
   touching the column-1 mascot/icon block.

Refs wxtsky#141.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: Settings → Plugin Sub-Sessions: separate / merge / hide (wxtsky#123)

When a plugin running inside another agent (e.g. omo inside OpenCode)
fires its own hook events without an explicit --source, bridge already
infers the real source via process ancestry (wxtsky#95). The plugin's events
end up with the right source but a different session_id from the main
session, so the user sees two same-source pets — confusing for users
who think of the plugin as part of the main session.

Add a 3-way Settings toggle to control how those events render:

- separate (default, current behavior): plugin events keep their own
  session_id, render as their own card.
- merge: rewrite session_id to the matching main session (same source,
  same _ppid). Plugin events fold into the parent agent's card.
- hide: drop plugin events entirely; PermissionRequest gets an auto-allow
  so plugin tool execution isn't blocked by a UI prompt the user
  asked to suppress.

Implementation:

- Bridge: stamp `_via_plugin = true` when sourceTag is nil but
  inferSource succeeded (this is exactly the "plugin proxy" path).
- HookServer.processRequest: pre-filter `_via_plugin` events per the
  pluginSessionMode setting before HookEvent construction. Merge looks
  up AppState.findSessionId(forSource:ppid:) using SessionSnapshot.cliPid
  (which already mirrors bridge's _ppid). Hide returns the right
  payload for blocking events.
- Settings + SettingsView: new pluginSessionMode key (default "separate"
  preserves existing behavior), Picker added to the Sessions section.
- L10n: en / zh / ja / ko / tr translations for title, desc, and the
  three options.

Existing users will need to Reinstall Hooks once so the new bridge
binary stamps `_via_plugin`. Without the new bridge, all paths behave
exactly as before (separate).

Refs wxtsky#123.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: hook event ring buffer + diagnostics export (wxtsky#103)

Issue wxtsky#103 (qwen problem 1: prompts not surfacing) and wxtsky#117 (Hermes
hooks not flowing) both stalled on the same thing — without runtime
visibility into which events HookServer accepted with what source /
session_id, all triage was guessing. Add a 100-entry in-memory ring
that captures the post-merge view of every dispatched hook event,
exported to state/hook-events.json on diagnostics export.

Per event we keep just the fields needed to reconstruct routing
decisions: timestamp, source, sessionId (truncated), eventName,
toolName, and viaPlugin (wxtsky#123). Payloads stay out of the export so
bug reports remain a sane size.

Mechanism:
- AppState gains a nested DiagnosticHookEvent struct, an
  ObservationIgnored ring buffer, and a recordHookEvent(...) entry.
  Capped at 100 — older entries trimmed FIFO.
- HookServer.processRequest records right after HookEvent
  construction (so merged session_ids are reflected) and before the
  routeKind switch.
- DiagnosticsExporter adds a state/hook-events.json step alongside
  the existing sessions.json snapshot.

Refs wxtsky#103, wxtsky#117.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: harden UserPromptSubmit prompt extraction + richer hook diagnostics (wxtsky#103)

Two changes both aimed at the qwen "prompt not showing" symptom:

1. Reducer prompt extraction
- Switch UserPromptSubmit's prompt extraction to firstStringFromEvent so
  it walks the same key list across both top-level and nested `payload`
  / `data` containers that the rest of the reducer already supports.
- Add `userPrompt` / `text` to the candidate keys.
- The helper trims and skips empty/whitespace-only values, so a hook
  that fires with `"prompt": ""` no longer inserts a blank chat row
  that pushes the rest of the UI around.

2. Hook ring buffer (wxtsky#103 follow-up)
- DiagnosticHookEvent now also captures `payloadKeys` (top-level field
  names with bridge-injected `_*` filtered out) and `promptPreview`
  (first 80 chars of any extracted prompt).
- HookServer computes both right after HookEvent construction and
  passes them into recordHookEvent.
- DiagnosticsExporter writes them into state/hook-events.json.

Together these answer the question that's blocked qwen / hermes /
antigravity triage: when a user reports "my prompt isn't showing",
exporting diagnostics now tells us at a glance whether (a) the hook
didn't fire at all (no entry), (b) it fired without a prompt field
(payloadKeys lists everything but `prompt` and promptPreview is null),
or (c) it fired with a prompt but the UI still dropped it
(promptPreview shows the text and we know to look at the SwiftUI path).

Refs wxtsky#103, wxtsky#117.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: distinguish cursor-agent / qodercli from desktop IDE source (wxtsky#134)

cursor-agent and qodercli share their hooks file (~/.cursor/hooks.json,
~/.qoder/settings.json) with the matching desktop IDE, so the bridge
hook command is identical for both. Until now everything fired through
that file landed as `--source cursor` / `--source qoder`, with the
session displayed as "Cursor" / "Qoder" — confusing for terminal users
who never opened the IDE.

Add `cursor-cli` and `qoder-cli` as first-class sources:

- SessionSnapshot.supportedSources / normalizedSupportedSource gain the
  two new keys plus aliases ("cursor-agent", "cursoragent",
  "cursorcli", "qodercli").
- sourceLabel renders them as "Cursor CLI" / "Qoder CLI".
- ESP32Protocol.MascotID.init reuses the existing .cursor / .qoder
  mascot slots — Buddy firmware only has 16 mascot slots and the
  visual identity is the same. (No firmware change required.)
- CLIProcessResolver.sourceMatchesExecutablePath learns to recognize
  cursor-agent (`/.local/share/cursor-agent/...` or `/cursor-agent/index.js`)
  and qodercli (`/qodercli` or `/@qoder-ai/qodercli`).
- New `cliVariantOverride(declaredSource:ancestry:)` promotes a
  `--source cursor` tag to "cursor-cli" when the ancestry has a
  cursor-agent binary (same for qoder → qoder-cli). The bridge calls
  it after sourceTag/inferSource so explicit tags also get promoted.
- inferSource scans `-cli` variants before plain ones so a path
  ending in `cursor-agent` doesn't get mis-attributed to `cursor`.

Terminal-jump routing uses session.termBundleId / termApp which already
points at the user's terminal app, so no TerminalActivator change is
needed — clicking a Cursor CLI session jumps to the host terminal,
not to Cursor.app.

Existing users need to Reinstall Hooks once so the rebuilt bridge ships
with cliVariantOverride.

Refs wxtsky#134.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: review-driven hardening of session changes

Three issues surfaced by independent code review against the just-landed
batch (ProcessRunner / HookServer plugin filter / subagent tooltip):

1. ProcessRunner data race (Swift 6 strict-concurrency)
   `var data` mutated inside a Sendable closure and read on the calling
   thread compiled cleanly under Swift 5 mode but Swift 6 will flag the
   captured-var write. Wrap the slot in a private `@unchecked Sendable`
   reference cell so the synchronization (drained.signal happens-before
   drained.wait) is explicit.

2. HookServer plugin filter ran JSONSerialization on every hook event
   The pre-filter for `_via_plugin` was parsing every incoming payload
   on the main actor — wasteful for the common case where the marker
   isn't there. Probe the raw bytes for the literal `_via_plugin`
   string first; only fall through to JSON parsing on a hit.

3. Subagent tooltip dropped `toolDescription`
   The previous commit's message advertised "Explore — Read src/main.swift"
   but the actual code only rendered the bare tool name. Fold
   `sub.toolDescription` into the detail when present and replace the
   force-unwrap with a flatMap guard while we're in there.

Refs wxtsky#103, wxtsky#123, wxtsky#139, wxtsky#141.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: clean up remaining review nits from session

Five smaller follow-ups from the same review pass — all flagged minor
or nit, but the ask was to clear the list, not pick:

1. cachedClaudeVersion race (NSLock)
   `detectClaudeVersion` is now reachable from two `Task.detached`
   call sites (wxtsky#139 install + verifyAndRepair). The static cache
   field was unguarded. Wrap read+write in an NSLock — writes are
   idempotent so functional impact was nil, but Swift 6 strict
   concurrency would flag it and a future refactor could turn the
   benign race into a real one.

2. firstStringFromDict no longer trims caller-visible value
   The reducer helper used to return `value.trimmingCharacters(...)`
   which silently stripped intentional leading / trailing whitespace
   from code-snippet prompts. Switch the trim to be empty-check-only
   and return the original value so chat history preserves the user's
   exact text.

3. Diagnostics export description (en / zh / ja / ko / tr)
   `state/hook-events.json` includes a 80-char prompt preview. The
   button description didn't say so. Update all five locales to spell
   out what the zip contains so users aren't surprised by including
   prompt text in a bug report.

4. findSessionId 5-min activity window
   Plugin merge previously matched any session sharing source +
   cliPid, including sessions whose CLI long since exited and whose
   PID may have been recycled by macOS for an unrelated process.
   Require lastActivity within 5 minutes — live sessions update on
   every event so the window is generous.

5. activateGhostty avoids double dispatch
   `activateGhostty` body already runs inside `DispatchQueue.global`
   (wxtsky#139). It then called `runOsaScript` which dispatched a second
   time. Add a `runOsaScriptSync` variant for callers already on a
   background queue and use it from activateGhostty.

Refs wxtsky#103, wxtsky#123, wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: empty default autoApproveTools

Previous default silently auto-approved 9 internal agent tools
(TaskCreate / TaskUpdate / TaskGet / TaskList / TaskOutput / TaskStop
/ TodoRead / TodoWrite / EnterPlanMode), which hid those calls from
the approval panel — users who never opened Settings → Auto-Approve
Tools didn't know that hiding was happening.

Switch the default to the empty string. Every tool call now flows
through the regular approval path; users who want the old behavior
can opt-in per tool from the Auto-Approve Tools list.

Existing users who'd already toggled the list explicitly are
unaffected — UserDefaults register only fills in absent keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf: extract subagent tooltip builder out of ViewBuilder

User reported the hover-triggered island expand animation felt
janky after the recent batch landed. The most plausible regression
was the subagent ForEach in wxtsky#141 — the review fix in be9231a
swelled the inline tooltip computation to five nested `let`s plus
an IIFE plus `flatMap` plus `.map`. That kind of expression in a
SwiftUI ViewBuilder triggers slow-path type inference and forces
the body to re-evaluate the whole closure tree on every hover
state flip / animation tick.

Move the logic to a file-private `subagentTooltipText(_:)` plain
Swift function. The ForEach body is now just two lines:
`MiniAgentIcon(...).help(subagentTooltipText(sub))`. Output is
identical to the previous version.

Refs wxtsky#141.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* release: v1.0.24

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: appcast.xml entry for v1.0.24

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* build: codesign DMG container before notarization

build-dmg.sh was creating an unsigned DMG container — the inner .app
got Developer ID signed and the dmg got stapled with a notary ticket,
but the dmg envelope itself had no signature. \`spctl --assess\` reports
"no usable signature" in that state, and Sparkle's update flow can
abort with "An error occurred while running the updater" when the
helper hand-off picks up the unsigned container.

Sign the DMG with the same Developer ID identity (timestamped) before
submitting to notarytool. The signature applies before notarization,
so the Apple ticket attaches to the signed container.

This is a latent issue — both v1.0.23 and v1.0.24 dmgs ship unsigned
containers; the symptom was intermittent because most code paths only
verify the inner .app + the staple ticket. v1.0.25+ won't have it.

Workaround for users hitting the error on v1.0.24: download the dmg
manually from the GitHub release page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: stop blanket-draining pending permissions on activity events (wxtsky#147)

handleEvent's wasWaiting branch used to drain the entire permission
queue for a session whenever an activity event arrived in
waitingApproval/waitingQuestion. The "user replied in terminal"
heuristic misfires for parallel MCP / plugin tool calls: e.g. the
official Notion plugin fetching 2 pages in parallel — the first
PostToolUse arrives while the second tool's PermissionRequest is
still pending, the blanket drain denies it, and the user sees
"briefly shown then auto-denied".

Switch to surgical drain only — resolveToolUseIfCompleted already
removes queue entries by tool_use_id when PostToolUse /
PostToolUseFailure / PermissionDenied for the same id arrives. Other
pending requests are unrelated parallel work and must keep waiting.

Question queue still gets drained (questions are rare and don't
carry tool_use_id reliably), and session status only resets to
idle/processing when nothing is left pending for the session.

Also threads a `reason:` argument through drainPermissions /
drainQuestions and adds log.notice traces at every auto-deny site
(resolveToolUseIfCompleted, mergeDuplicatePermissionRequest,
drainPermissions/Questions internal). Surfaced as "⚠️ permission
deny reason=… session=… toolUseId=… tool=…" in com.codeisland
subsystem so future "card flashed and disappeared" reports can be
diagnosed from Console.app without a custom build.

Tests: add 2 wxtsky#147 regressions
(testStopEventDoesNotDenyPendingPermission,
testParallelPostToolUseDoesNotDenyUnrelatedPendingPermission).
241/241 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: collapse Cursor sub-agent processes onto one session card (wxtsky#148)

Cursor IDE spawns N parallel sub-agent subprocesses per logical session
(file search, code analysis, etc.), and each sub-agent runs its own
hook subprocess with a different immediate ppid. Bridge's session_id
fallback used `cursor-ppid-<getppid()>`, fanning each sub-agent into a
separate session card — users saw 10+ "Cursor — thinking" cards for
what was logically one session, drowning out the actual session they
were interacting with.

Add CLIProcessResolver.resolvedSessionPID — same shape as
resolvedTrackedPID but picks the *root-most* same-source binary in the
ancestry instead of the nearest. Bridge's session_id fallback now uses
it, so all sub-agents spawned by the same root cursor-agent collapse
onto a single session_id.

To call resolvedSessionPID at fallback time we need ancestry already
computed, so the ancestry/effectiveSource block was moved up in
main.swift to run before the session_id fallback. Same code, earlier
position — `_ppid` / `_via_plugin` / `_source` semantics unchanged.

resolvedTrackedPID still uses `first(where:)` (nearest binary) because
its job — telling AppState which PID to monitor for liveness — wants
the closest CLI process, not the root one.

Tests: new CLIProcessResolverTests covering parallel sub-agents
collapsing to the same root PID, root-most vs nearest distinction
between the two resolvers, and fallbacks (no match, empty ancestry,
nil source, non-positive immediate ppid). 247/247 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: honor user default mascot whenever no session is actively working (wxtsky#149)

wxtsky#102 fixed the empty-session case (no sessions → use default mascot),
but `refreshDerivedState` and `CompactLeftWing.displaySource` still
echoed `deriveSessionSummary`'s `mostRecentIdleSource` whenever any
session existed — even all-idle ones. Result: a user who picked Codex
as the default mascot still saw Claude every time their last session
went idle, because the most recently active session's source kept
"sticking" to the notch.

Switch the trigger from `totalSessionCount == 0` to `summary.status ==
.idle`, covering both empty-state (wxtsky#102) and all-idle (wxtsky#149) under
one rule. Active work (running / processing / waitingApproval /
waitingQuestion) still wins — we don't want to mute the source of
something actively happening just because the user has a preferred
idle mascot.

NotchPanelView's `CompactLeftWing.displaySource` mirrors the same
logic so the compact wing renders the user default in idle even
when the picked-out displaySession happens to be an idle one.

Tests: 4 new regressions in AppStatePrimarySourceTests
(idle-honors-default, active-overrides-default, empty-honors-default,
mixed-active-and-idle-uses-active). 251/251 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Arda Kılıçdağı <ardakilicdagi@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: jerry0804 <jerryzhao0804@gmail.com>
Co-authored-by: JZ <jz@JZdeMacBook-Pro.local>
Co-authored-by: Drswith <49299002+Drswith@users.noreply.github.com>
Co-authored-by: K0ala <liushaoxiong10@outlook.com>
Co-authored-by: wxtsky <1970550145@qq.com>
Uncle-Peke added a commit to Uncle-Peke/CodeIsland that referenced this pull request May 4, 2026
* chore: polish Turkish translation for stylistic consistency (wxtsky#135)

Align 4 post-PR entries with the established style (Title Case for
setting labels, ALL CAPS for action buttons, correct semantics):

- auto_collapse_after_session_jump: lowercase → Title Case
- dismiss: ERTELE (postpone) → YOKSAY (ignore/dismiss)
- buddy_reconnect: sentence case → Title Case
- buddy_enable_bluetooth: sentence case → Title Case

Key parity unchanged (259/259 keys covered).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: prevent Sparkle crash in DEBUG mode when running without bundle ID (wxtsky#133)

Co-authored-by: JZ <jz@JZdeMacBook-Pro.local>

* fix: avoid stale update abort errors (wxtsky#138)

* feat: improve Buddy watch approval previews and alerts (wxtsky#144)

Show the full Bash approval command alongside the human-readable description so pending approvals stay understandable on macOS and on the watch. Preserve pending TraeCLI approvals until the user responds, add watch-side approve/deny and skip controls over BLE, and trigger vibration plus richer attention notifications when approval is required.

* test: align AppStateQuestionFlow assertions with wxtsky#144 toolDescription change

wxtsky#144 changed `HookEvent.toolDescription` for Bash to combine description and
command (so ESP32/watch previews show the full command). Update the existing
two assertions to match the new format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: add ProcessRunner with timeout to harden detectClaudeVersion (wxtsky#139)

Replace the unbounded waitUntilExit() in detectClaudeVersion with a shared
ProcessRunner that enforces a hard deadline (SIGTERM, then SIGKILL after 1s
grace) and drains stdout off the wait path so a full pipe buffer cannot
wedge the child. detectClaudeVersion now uses a 5s timeout — a stuck
`claude --version` used to freeze app launch silently.

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: move launch-time hook installation off the main thread (wxtsky#139)

ConfigInstaller.install() does subprocess version detection plus disk I/O
across multiple CLI configs. Running it inline from
applicationDidFinishLaunching meant a slow CLI binary or network home dir
froze the menu bar / panel until install finished — sometimes silently,
because macOS doesn't pop a "Not Responding" dialog for status items.

Detach it onto a userInitiated Task so app UI comes up immediately while
hooks install in the background. HookServer is still started synchronously
before the detach so the socket is listening by the time settings.json is
rewritten (preserves the ordering noted in the existing comment).

Mark AppDelegate.log nonisolated so the detached task can use it without
violating Swift 6 actor isolation (Logger is Sendable).

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: run periodic hook verifyAndRepair off the main thread (wxtsky#139)

checkAndRepairHooks fires from a 300s timer AND every NSWorkspace
didActivateApplicationNotification (i.e. each time the user app-switches).
verifyAndRepair walks every enabled CLI and rewrites settings.json — on a
network home dir or a slow disk that's enough to stutter the panel.

Keep the 60s debounce on the main actor (cheap), but do the actual repair
on a background task. Repair results only feed a log line so no UI thread
hand-off is needed.

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: cap subprocess waits in TerminalActivator/VisibilityDetector (wxtsky#139)

Both files reimplemented the same Process+Pipe+waitUntilExit pattern with
no timeout. Activate() and the visibility checks fire from the main
thread (panel buttons, shortcut handler, NotchPanelView body, etc.), so a
stuck osascript / tmux / iTerm CLI was free to freeze the UI for as long
as the child took to wake up.

Replace both runProcess implementations with ProcessRunner.run:

- TerminalActivator: 10s cap (matches current osascript-tab-switch
  worst-case behaviour; activateCmux still dispatches off-main on its
  own, the timeout is just a backstop).
- TerminalVisibilityDetector: 5s cap (these are speculative "is the tab
  visible?" probes called frequently from view bodies — fail fast).

Removes ~30 lines of duplicated boilerplate. No behaviour change on the
happy path; on a hang, callers see nil instead of a frozen UI.

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: dispatch Ghostty activation off the main thread (wxtsky#139)

activateGhostty was the last activator that still resolved its
subprocess inputs synchronously on the main thread. It calls
`tmux display-message` to fetch a session/window key (used to match
the right Ghostty terminal in the AppleScript template) and then
hands the script off to runOsaScript. Every other activateXxx already
runs on a background queue (activateITerm/Warp/WezTerm/Kitty/Tmux/IDE
window via runAppleScript or an explicit DispatchQueue.global; cmux
has its own dispatch).

Move the same pattern here: keep the AppKit gating (NSWorkspace
runningApplications + unhide) on the main thread where it has to live,
then dispatch the tmux probe, AppleScript construction, and
osascript invocation onto a userInitiated queue. With this change no
TerminalActivator code path can hold the main thread on a stuck
subprocess, which closes the loop on issue wxtsky#139.

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: register Codex PermissionRequest hook so approvals trigger UI + sound (wxtsky#145)

Codex CLI supports a PermissionRequest hook event (per
developers.openai.com/codex/hooks) that fires when Codex needs user
approval — shell escalation, managed-network access, etc. CodeIsland's
Codex CLIConfig only registered SessionStart/End, UserPromptSubmit,
PreToolUse, PostToolUse, and Stop, so this event was never installed
into Codex's hooks.json.

Result: when Codex hit an approval gate, the panel stayed in "running"
status forever, no approval card opened, and SoundManager wasn't
triggered (handlePermissionRequest is only called from the
PermissionRequest event path).

Add the event with the same shape as Claude's: 24h timeout, blocking
(async=false) so Codex waits for the user's allow/deny decision.

Existing users need to hit Settings → Reinstall Hooks once to rewrite
~/.codex/hooks.json with the new entry; new installs pick it up
automatically.

Refs wxtsky#145.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: setting to disable auto-expand on agent completion (wxtsky#146)

Adds Settings → Behavior → "Auto-Expand Panel on Agent Completion"
(default On — preserves current behavior). When off, CodeIsland stays
in its compact state when agents/subagents finish; status indicators,
the queue, and manual interactions are unaffected.

Implementation:
- New SettingsKey/SettingsDefaults entry registered with default true
  so existing users keep the current behavior across upgrades.
- AppState.enqueueCompletion early-returns when the toggle is off.
  This is the single chokepoint — both Stop events from the reducer
  and any other paths into completion display funnel through here, so
  one guard covers all entry points.
- BehaviorToggleRow added next to the existing auto-collapse toggle,
  reusing the .smartSuppress animation icon (semantically the closest
  "suppress auto-expand" preview we already have).
- Translations added for en / zh / ja / ko / tr.

Refs wxtsky#146.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: prefer opencode.jsonc over opencode.json when registering plugin (wxtsky#132)

OpenCode's troubleshooting guide recommends opencode.jsonc (with-comments
JSON) as the canonical config filename. Before this change, CodeIsland
only ever read/wrote ~/.config/opencode/opencode.json, so users on
opencode.jsonc would see CodeIsland resurrect a sibling opencode.json
on every install / repair pass.

Pick the target by precedence at install time:
1. opencode.jsonc — write here when it already exists (OpenCode-recommended)
2. opencode.json   — fallback for existing setups, also the default for fresh installs
3. config.json     — legacy, still cleaned up after migration

uninstall and isOpencodePluginInstalled now scan all three. The existing
mergeOpencodePluginRef path already runs through stripJSONComments, so
.jsonc input parses correctly without changes.

Refs wxtsky#132.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: surface subagent count + agent type tooltip on session card (wxtsky#141)

Subagents already rendered as 8px MiniAgentIcons under the mascot but
were visually identical and unlabelled — users couldn't tell at a glance
which sessions had spawned background work, or what each subagent was
actually doing.

Two additions, no layout changes:

1. Each MiniAgentIcon now carries a .help(...) tooltip that surfaces
   the agent type and current tool (e.g. "Explore — Read src/main.swift",
   or just "general-purpose" when no tool is active).
2. A new SessionTag "+N Sub" (purple) appears in the session header tag
   row alongside @Remote / INT / YOLO when session.subagents is
   non-empty — gives the at-a-glance count the issue asked for without
   touching the column-1 mascot/icon block.

Refs wxtsky#141.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: Settings → Plugin Sub-Sessions: separate / merge / hide (wxtsky#123)

When a plugin running inside another agent (e.g. omo inside OpenCode)
fires its own hook events without an explicit --source, bridge already
infers the real source via process ancestry (wxtsky#95). The plugin's events
end up with the right source but a different session_id from the main
session, so the user sees two same-source pets — confusing for users
who think of the plugin as part of the main session.

Add a 3-way Settings toggle to control how those events render:

- separate (default, current behavior): plugin events keep their own
  session_id, render as their own card.
- merge: rewrite session_id to the matching main session (same source,
  same _ppid). Plugin events fold into the parent agent's card.
- hide: drop plugin events entirely; PermissionRequest gets an auto-allow
  so plugin tool execution isn't blocked by a UI prompt the user
  asked to suppress.

Implementation:

- Bridge: stamp `_via_plugin = true` when sourceTag is nil but
  inferSource succeeded (this is exactly the "plugin proxy" path).
- HookServer.processRequest: pre-filter `_via_plugin` events per the
  pluginSessionMode setting before HookEvent construction. Merge looks
  up AppState.findSessionId(forSource:ppid:) using SessionSnapshot.cliPid
  (which already mirrors bridge's _ppid). Hide returns the right
  payload for blocking events.
- Settings + SettingsView: new pluginSessionMode key (default "separate"
  preserves existing behavior), Picker added to the Sessions section.
- L10n: en / zh / ja / ko / tr translations for title, desc, and the
  three options.

Existing users will need to Reinstall Hooks once so the new bridge
binary stamps `_via_plugin`. Without the new bridge, all paths behave
exactly as before (separate).

Refs wxtsky#123.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: hook event ring buffer + diagnostics export (wxtsky#103)

Issue wxtsky#103 (qwen problem 1: prompts not surfacing) and wxtsky#117 (Hermes
hooks not flowing) both stalled on the same thing — without runtime
visibility into which events HookServer accepted with what source /
session_id, all triage was guessing. Add a 100-entry in-memory ring
that captures the post-merge view of every dispatched hook event,
exported to state/hook-events.json on diagnostics export.

Per event we keep just the fields needed to reconstruct routing
decisions: timestamp, source, sessionId (truncated), eventName,
toolName, and viaPlugin (wxtsky#123). Payloads stay out of the export so
bug reports remain a sane size.

Mechanism:
- AppState gains a nested DiagnosticHookEvent struct, an
  ObservationIgnored ring buffer, and a recordHookEvent(...) entry.
  Capped at 100 — older entries trimmed FIFO.
- HookServer.processRequest records right after HookEvent
  construction (so merged session_ids are reflected) and before the
  routeKind switch.
- DiagnosticsExporter adds a state/hook-events.json step alongside
  the existing sessions.json snapshot.

Refs wxtsky#103, wxtsky#117.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: harden UserPromptSubmit prompt extraction + richer hook diagnostics (wxtsky#103)

Two changes both aimed at the qwen "prompt not showing" symptom:

1. Reducer prompt extraction
- Switch UserPromptSubmit's prompt extraction to firstStringFromEvent so
  it walks the same key list across both top-level and nested `payload`
  / `data` containers that the rest of the reducer already supports.
- Add `userPrompt` / `text` to the candidate keys.
- The helper trims and skips empty/whitespace-only values, so a hook
  that fires with `"prompt": ""` no longer inserts a blank chat row
  that pushes the rest of the UI around.

2. Hook ring buffer (wxtsky#103 follow-up)
- DiagnosticHookEvent now also captures `payloadKeys` (top-level field
  names with bridge-injected `_*` filtered out) and `promptPreview`
  (first 80 chars of any extracted prompt).
- HookServer computes both right after HookEvent construction and
  passes them into recordHookEvent.
- DiagnosticsExporter writes them into state/hook-events.json.

Together these answer the question that's blocked qwen / hermes /
antigravity triage: when a user reports "my prompt isn't showing",
exporting diagnostics now tells us at a glance whether (a) the hook
didn't fire at all (no entry), (b) it fired without a prompt field
(payloadKeys lists everything but `prompt` and promptPreview is null),
or (c) it fired with a prompt but the UI still dropped it
(promptPreview shows the text and we know to look at the SwiftUI path).

Refs wxtsky#103, wxtsky#117.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: distinguish cursor-agent / qodercli from desktop IDE source (wxtsky#134)

cursor-agent and qodercli share their hooks file (~/.cursor/hooks.json,
~/.qoder/settings.json) with the matching desktop IDE, so the bridge
hook command is identical for both. Until now everything fired through
that file landed as `--source cursor` / `--source qoder`, with the
session displayed as "Cursor" / "Qoder" — confusing for terminal users
who never opened the IDE.

Add `cursor-cli` and `qoder-cli` as first-class sources:

- SessionSnapshot.supportedSources / normalizedSupportedSource gain the
  two new keys plus aliases ("cursor-agent", "cursoragent",
  "cursorcli", "qodercli").
- sourceLabel renders them as "Cursor CLI" / "Qoder CLI".
- ESP32Protocol.MascotID.init reuses the existing .cursor / .qoder
  mascot slots — Buddy firmware only has 16 mascot slots and the
  visual identity is the same. (No firmware change required.)
- CLIProcessResolver.sourceMatchesExecutablePath learns to recognize
  cursor-agent (`/.local/share/cursor-agent/...` or `/cursor-agent/index.js`)
  and qodercli (`/qodercli` or `/@qoder-ai/qodercli`).
- New `cliVariantOverride(declaredSource:ancestry:)` promotes a
  `--source cursor` tag to "cursor-cli" when the ancestry has a
  cursor-agent binary (same for qoder → qoder-cli). The bridge calls
  it after sourceTag/inferSource so explicit tags also get promoted.
- inferSource scans `-cli` variants before plain ones so a path
  ending in `cursor-agent` doesn't get mis-attributed to `cursor`.

Terminal-jump routing uses session.termBundleId / termApp which already
points at the user's terminal app, so no TerminalActivator change is
needed — clicking a Cursor CLI session jumps to the host terminal,
not to Cursor.app.

Existing users need to Reinstall Hooks once so the rebuilt bridge ships
with cliVariantOverride.

Refs wxtsky#134.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: review-driven hardening of session changes

Three issues surfaced by independent code review against the just-landed
batch (ProcessRunner / HookServer plugin filter / subagent tooltip):

1. ProcessRunner data race (Swift 6 strict-concurrency)
   `var data` mutated inside a Sendable closure and read on the calling
   thread compiled cleanly under Swift 5 mode but Swift 6 will flag the
   captured-var write. Wrap the slot in a private `@unchecked Sendable`
   reference cell so the synchronization (drained.signal happens-before
   drained.wait) is explicit.

2. HookServer plugin filter ran JSONSerialization on every hook event
   The pre-filter for `_via_plugin` was parsing every incoming payload
   on the main actor — wasteful for the common case where the marker
   isn't there. Probe the raw bytes for the literal `_via_plugin`
   string first; only fall through to JSON parsing on a hit.

3. Subagent tooltip dropped `toolDescription`
   The previous commit's message advertised "Explore — Read src/main.swift"
   but the actual code only rendered the bare tool name. Fold
   `sub.toolDescription` into the detail when present and replace the
   force-unwrap with a flatMap guard while we're in there.

Refs wxtsky#103, wxtsky#123, wxtsky#139, wxtsky#141.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: clean up remaining review nits from session

Five smaller follow-ups from the same review pass — all flagged minor
or nit, but the ask was to clear the list, not pick:

1. cachedClaudeVersion race (NSLock)
   `detectClaudeVersion` is now reachable from two `Task.detached`
   call sites (wxtsky#139 install + verifyAndRepair). The static cache
   field was unguarded. Wrap read+write in an NSLock — writes are
   idempotent so functional impact was nil, but Swift 6 strict
   concurrency would flag it and a future refactor could turn the
   benign race into a real one.

2. firstStringFromDict no longer trims caller-visible value
   The reducer helper used to return `value.trimmingCharacters(...)`
   which silently stripped intentional leading / trailing whitespace
   from code-snippet prompts. Switch the trim to be empty-check-only
   and return the original value so chat history preserves the user's
   exact text.

3. Diagnostics export description (en / zh / ja / ko / tr)
   `state/hook-events.json` includes a 80-char prompt preview. The
   button description didn't say so. Update all five locales to spell
   out what the zip contains so users aren't surprised by including
   prompt text in a bug report.

4. findSessionId 5-min activity window
   Plugin merge previously matched any session sharing source +
   cliPid, including sessions whose CLI long since exited and whose
   PID may have been recycled by macOS for an unrelated process.
   Require lastActivity within 5 minutes — live sessions update on
   every event so the window is generous.

5. activateGhostty avoids double dispatch
   `activateGhostty` body already runs inside `DispatchQueue.global`
   (wxtsky#139). It then called `runOsaScript` which dispatched a second
   time. Add a `runOsaScriptSync` variant for callers already on a
   background queue and use it from activateGhostty.

Refs wxtsky#103, wxtsky#123, wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: empty default autoApproveTools

Previous default silently auto-approved 9 internal agent tools
(TaskCreate / TaskUpdate / TaskGet / TaskList / TaskOutput / TaskStop
/ TodoRead / TodoWrite / EnterPlanMode), which hid those calls from
the approval panel — users who never opened Settings → Auto-Approve
Tools didn't know that hiding was happening.

Switch the default to the empty string. Every tool call now flows
through the regular approval path; users who want the old behavior
can opt-in per tool from the Auto-Approve Tools list.

Existing users who'd already toggled the list explicitly are
unaffected — UserDefaults register only fills in absent keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf: extract subagent tooltip builder out of ViewBuilder

User reported the hover-triggered island expand animation felt
janky after the recent batch landed. The most plausible regression
was the subagent ForEach in wxtsky#141 — the review fix in be9231a
swelled the inline tooltip computation to five nested `let`s plus
an IIFE plus `flatMap` plus `.map`. That kind of expression in a
SwiftUI ViewBuilder triggers slow-path type inference and forces
the body to re-evaluate the whole closure tree on every hover
state flip / animation tick.

Move the logic to a file-private `subagentTooltipText(_:)` plain
Swift function. The ForEach body is now just two lines:
`MiniAgentIcon(...).help(subagentTooltipText(sub))`. Output is
identical to the previous version.

Refs wxtsky#141.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* release: v1.0.24

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: appcast.xml entry for v1.0.24

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* build: codesign DMG container before notarization

build-dmg.sh was creating an unsigned DMG container — the inner .app
got Developer ID signed and the dmg got stapled with a notary ticket,
but the dmg envelope itself had no signature. \`spctl --assess\` reports
"no usable signature" in that state, and Sparkle's update flow can
abort with "An error occurred while running the updater" when the
helper hand-off picks up the unsigned container.

Sign the DMG with the same Developer ID identity (timestamped) before
submitting to notarytool. The signature applies before notarization,
so the Apple ticket attaches to the signed container.

This is a latent issue — both v1.0.23 and v1.0.24 dmgs ship unsigned
containers; the symptom was intermittent because most code paths only
verify the inner .app + the staple ticket. v1.0.25+ won't have it.

Workaround for users hitting the error on v1.0.24: download the dmg
manually from the GitHub release page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: stop blanket-draining pending permissions on activity events (wxtsky#147)

handleEvent's wasWaiting branch used to drain the entire permission
queue for a session whenever an activity event arrived in
waitingApproval/waitingQuestion. The "user replied in terminal"
heuristic misfires for parallel MCP / plugin tool calls: e.g. the
official Notion plugin fetching 2 pages in parallel — the first
PostToolUse arrives while the second tool's PermissionRequest is
still pending, the blanket drain denies it, and the user sees
"briefly shown then auto-denied".

Switch to surgical drain only — resolveToolUseIfCompleted already
removes queue entries by tool_use_id when PostToolUse /
PostToolUseFailure / PermissionDenied for the same id arrives. Other
pending requests are unrelated parallel work and must keep waiting.

Question queue still gets drained (questions are rare and don't
carry tool_use_id reliably), and session status only resets to
idle/processing when nothing is left pending for the session.

Also threads a `reason:` argument through drainPermissions /
drainQuestions and adds log.notice traces at every auto-deny site
(resolveToolUseIfCompleted, mergeDuplicatePermissionRequest,
drainPermissions/Questions internal). Surfaced as "⚠️ permission
deny reason=… session=… toolUseId=… tool=…" in com.codeisland
subsystem so future "card flashed and disappeared" reports can be
diagnosed from Console.app without a custom build.

Tests: add 2 wxtsky#147 regressions
(testStopEventDoesNotDenyPendingPermission,
testParallelPostToolUseDoesNotDenyUnrelatedPendingPermission).
241/241 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: collapse Cursor sub-agent processes onto one session card (wxtsky#148)

Cursor IDE spawns N parallel sub-agent subprocesses per logical session
(file search, code analysis, etc.), and each sub-agent runs its own
hook subprocess with a different immediate ppid. Bridge's session_id
fallback used `cursor-ppid-<getppid()>`, fanning each sub-agent into a
separate session card — users saw 10+ "Cursor — thinking" cards for
what was logically one session, drowning out the actual session they
were interacting with.

Add CLIProcessResolver.resolvedSessionPID — same shape as
resolvedTrackedPID but picks the *root-most* same-source binary in the
ancestry instead of the nearest. Bridge's session_id fallback now uses
it, so all sub-agents spawned by the same root cursor-agent collapse
onto a single session_id.

To call resolvedSessionPID at fallback time we need ancestry already
computed, so the ancestry/effectiveSource block was moved up in
main.swift to run before the session_id fallback. Same code, earlier
position — `_ppid` / `_via_plugin` / `_source` semantics unchanged.

resolvedTrackedPID still uses `first(where:)` (nearest binary) because
its job — telling AppState which PID to monitor for liveness — wants
the closest CLI process, not the root one.

Tests: new CLIProcessResolverTests covering parallel sub-agents
collapsing to the same root PID, root-most vs nearest distinction
between the two resolvers, and fallbacks (no match, empty ancestry,
nil source, non-positive immediate ppid). 247/247 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: honor user default mascot whenever no session is actively working (wxtsky#149)

wxtsky#102 fixed the empty-session case (no sessions → use default mascot),
but `refreshDerivedState` and `CompactLeftWing.displaySource` still
echoed `deriveSessionSummary`'s `mostRecentIdleSource` whenever any
session existed — even all-idle ones. Result: a user who picked Codex
as the default mascot still saw Claude every time their last session
went idle, because the most recently active session's source kept
"sticking" to the notch.

Switch the trigger from `totalSessionCount == 0` to `summary.status ==
.idle`, covering both empty-state (wxtsky#102) and all-idle (wxtsky#149) under
one rule. Active work (running / processing / waitingApproval /
waitingQuestion) still wins — we don't want to mute the source of
something actively happening just because the user has a preferred
idle mascot.

NotchPanelView's `CompactLeftWing.displaySource` mirrors the same
logic so the compact wing renders the user default in idle even
when the picked-out displaySession happens to be an idle one.

Tests: 4 new regressions in AppStatePrimarySourceTests
(idle-honors-default, active-overrides-default, empty-honors-default,
mixed-active-and-idle-uses-active). 251/251 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Arda Kılıçdağı <ardakilicdagi@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: jerry0804 <jerryzhao0804@gmail.com>
Co-authored-by: JZ <jz@JZdeMacBook-Pro.local>
Co-authored-by: Drswith <49299002+Drswith@users.noreply.github.com>
Co-authored-by: K0ala <liushaoxiong10@outlook.com>
Co-authored-by: wxtsky <1970550145@qq.com>
Uncle-Peke added a commit to Uncle-Peke/CodeIsland that referenced this pull request May 15, 2026
* chore: polish Turkish translation for stylistic consistency (wxtsky#135)

Align 4 post-PR entries with the established style (Title Case for
setting labels, ALL CAPS for action buttons, correct semantics):

- auto_collapse_after_session_jump: lowercase → Title Case
- dismiss: ERTELE (postpone) → YOKSAY (ignore/dismiss)
- buddy_reconnect: sentence case → Title Case
- buddy_enable_bluetooth: sentence case → Title Case

Key parity unchanged (259/259 keys covered).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: prevent Sparkle crash in DEBUG mode when running without bundle ID (wxtsky#133)

Co-authored-by: JZ <jz@JZdeMacBook-Pro.local>

* fix: avoid stale update abort errors (wxtsky#138)

* feat: improve Buddy watch approval previews and alerts (wxtsky#144)

Show the full Bash approval command alongside the human-readable description so pending approvals stay understandable on macOS and on the watch. Preserve pending TraeCLI approvals until the user responds, add watch-side approve/deny and skip controls over BLE, and trigger vibration plus richer attention notifications when approval is required.

* test: align AppStateQuestionFlow assertions with wxtsky#144 toolDescription change

wxtsky#144 changed `HookEvent.toolDescription` for Bash to combine description and
command (so ESP32/watch previews show the full command). Update the existing
two assertions to match the new format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: add ProcessRunner with timeout to harden detectClaudeVersion (wxtsky#139)

Replace the unbounded waitUntilExit() in detectClaudeVersion with a shared
ProcessRunner that enforces a hard deadline (SIGTERM, then SIGKILL after 1s
grace) and drains stdout off the wait path so a full pipe buffer cannot
wedge the child. detectClaudeVersion now uses a 5s timeout — a stuck
`claude --version` used to freeze app launch silently.

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: move launch-time hook installation off the main thread (wxtsky#139)

ConfigInstaller.install() does subprocess version detection plus disk I/O
across multiple CLI configs. Running it inline from
applicationDidFinishLaunching meant a slow CLI binary or network home dir
froze the menu bar / panel until install finished — sometimes silently,
because macOS doesn't pop a "Not Responding" dialog for status items.

Detach it onto a userInitiated Task so app UI comes up immediately while
hooks install in the background. HookServer is still started synchronously
before the detach so the socket is listening by the time settings.json is
rewritten (preserves the ordering noted in the existing comment).

Mark AppDelegate.log nonisolated so the detached task can use it without
violating Swift 6 actor isolation (Logger is Sendable).

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: run periodic hook verifyAndRepair off the main thread (wxtsky#139)

checkAndRepairHooks fires from a 300s timer AND every NSWorkspace
didActivateApplicationNotification (i.e. each time the user app-switches).
verifyAndRepair walks every enabled CLI and rewrites settings.json — on a
network home dir or a slow disk that's enough to stutter the panel.

Keep the 60s debounce on the main actor (cheap), but do the actual repair
on a background task. Repair results only feed a log line so no UI thread
hand-off is needed.

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: cap subprocess waits in TerminalActivator/VisibilityDetector (wxtsky#139)

Both files reimplemented the same Process+Pipe+waitUntilExit pattern with
no timeout. Activate() and the visibility checks fire from the main
thread (panel buttons, shortcut handler, NotchPanelView body, etc.), so a
stuck osascript / tmux / iTerm CLI was free to freeze the UI for as long
as the child took to wake up.

Replace both runProcess implementations with ProcessRunner.run:

- TerminalActivator: 10s cap (matches current osascript-tab-switch
  worst-case behaviour; activateCmux still dispatches off-main on its
  own, the timeout is just a backstop).
- TerminalVisibilityDetector: 5s cap (these are speculative "is the tab
  visible?" probes called frequently from view bodies — fail fast).

Removes ~30 lines of duplicated boilerplate. No behaviour change on the
happy path; on a hang, callers see nil instead of a frozen UI.

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: dispatch Ghostty activation off the main thread (wxtsky#139)

activateGhostty was the last activator that still resolved its
subprocess inputs synchronously on the main thread. It calls
`tmux display-message` to fetch a session/window key (used to match
the right Ghostty terminal in the AppleScript template) and then
hands the script off to runOsaScript. Every other activateXxx already
runs on a background queue (activateITerm/Warp/WezTerm/Kitty/Tmux/IDE
window via runAppleScript or an explicit DispatchQueue.global; cmux
has its own dispatch).

Move the same pattern here: keep the AppKit gating (NSWorkspace
runningApplications + unhide) on the main thread where it has to live,
then dispatch the tmux probe, AppleScript construction, and
osascript invocation onto a userInitiated queue. With this change no
TerminalActivator code path can hold the main thread on a stuck
subprocess, which closes the loop on issue wxtsky#139.

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: register Codex PermissionRequest hook so approvals trigger UI + sound (wxtsky#145)

Codex CLI supports a PermissionRequest hook event (per
developers.openai.com/codex/hooks) that fires when Codex needs user
approval — shell escalation, managed-network access, etc. CodeIsland's
Codex CLIConfig only registered SessionStart/End, UserPromptSubmit,
PreToolUse, PostToolUse, and Stop, so this event was never installed
into Codex's hooks.json.

Result: when Codex hit an approval gate, the panel stayed in "running"
status forever, no approval card opened, and SoundManager wasn't
triggered (handlePermissionRequest is only called from the
PermissionRequest event path).

Add the event with the same shape as Claude's: 24h timeout, blocking
(async=false) so Codex waits for the user's allow/deny decision.

Existing users need to hit Settings → Reinstall Hooks once to rewrite
~/.codex/hooks.json with the new entry; new installs pick it up
automatically.

Refs wxtsky#145.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: setting to disable auto-expand on agent completion (wxtsky#146)

Adds Settings → Behavior → "Auto-Expand Panel on Agent Completion"
(default On — preserves current behavior). When off, CodeIsland stays
in its compact state when agents/subagents finish; status indicators,
the queue, and manual interactions are unaffected.

Implementation:
- New SettingsKey/SettingsDefaults entry registered with default true
  so existing users keep the current behavior across upgrades.
- AppState.enqueueCompletion early-returns when the toggle is off.
  This is the single chokepoint — both Stop events from the reducer
  and any other paths into completion display funnel through here, so
  one guard covers all entry points.
- BehaviorToggleRow added next to the existing auto-collapse toggle,
  reusing the .smartSuppress animation icon (semantically the closest
  "suppress auto-expand" preview we already have).
- Translations added for en / zh / ja / ko / tr.

Refs wxtsky#146.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: prefer opencode.jsonc over opencode.json when registering plugin (wxtsky#132)

OpenCode's troubleshooting guide recommends opencode.jsonc (with-comments
JSON) as the canonical config filename. Before this change, CodeIsland
only ever read/wrote ~/.config/opencode/opencode.json, so users on
opencode.jsonc would see CodeIsland resurrect a sibling opencode.json
on every install / repair pass.

Pick the target by precedence at install time:
1. opencode.jsonc — write here when it already exists (OpenCode-recommended)
2. opencode.json   — fallback for existing setups, also the default for fresh installs
3. config.json     — legacy, still cleaned up after migration

uninstall and isOpencodePluginInstalled now scan all three. The existing
mergeOpencodePluginRef path already runs through stripJSONComments, so
.jsonc input parses correctly without changes.

Refs wxtsky#132.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: surface subagent count + agent type tooltip on session card (wxtsky#141)

Subagents already rendered as 8px MiniAgentIcons under the mascot but
were visually identical and unlabelled — users couldn't tell at a glance
which sessions had spawned background work, or what each subagent was
actually doing.

Two additions, no layout changes:

1. Each MiniAgentIcon now carries a .help(...) tooltip that surfaces
   the agent type and current tool (e.g. "Explore — Read src/main.swift",
   or just "general-purpose" when no tool is active).
2. A new SessionTag "+N Sub" (purple) appears in the session header tag
   row alongside @Remote / INT / YOLO when session.subagents is
   non-empty — gives the at-a-glance count the issue asked for without
   touching the column-1 mascot/icon block.

Refs wxtsky#141.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: Settings → Plugin Sub-Sessions: separate / merge / hide (wxtsky#123)

When a plugin running inside another agent (e.g. omo inside OpenCode)
fires its own hook events without an explicit --source, bridge already
infers the real source via process ancestry (wxtsky#95). The plugin's events
end up with the right source but a different session_id from the main
session, so the user sees two same-source pets — confusing for users
who think of the plugin as part of the main session.

Add a 3-way Settings toggle to control how those events render:

- separate (default, current behavior): plugin events keep their own
  session_id, render as their own card.
- merge: rewrite session_id to the matching main session (same source,
  same _ppid). Plugin events fold into the parent agent's card.
- hide: drop plugin events entirely; PermissionRequest gets an auto-allow
  so plugin tool execution isn't blocked by a UI prompt the user
  asked to suppress.

Implementation:

- Bridge: stamp `_via_plugin = true` when sourceTag is nil but
  inferSource succeeded (this is exactly the "plugin proxy" path).
- HookServer.processRequest: pre-filter `_via_plugin` events per the
  pluginSessionMode setting before HookEvent construction. Merge looks
  up AppState.findSessionId(forSource:ppid:) using SessionSnapshot.cliPid
  (which already mirrors bridge's _ppid). Hide returns the right
  payload for blocking events.
- Settings + SettingsView: new pluginSessionMode key (default "separate"
  preserves existing behavior), Picker added to the Sessions section.
- L10n: en / zh / ja / ko / tr translations for title, desc, and the
  three options.

Existing users will need to Reinstall Hooks once so the new bridge
binary stamps `_via_plugin`. Without the new bridge, all paths behave
exactly as before (separate).

Refs wxtsky#123.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: hook event ring buffer + diagnostics export (wxtsky#103)

Issue wxtsky#103 (qwen problem 1: prompts not surfacing) and wxtsky#117 (Hermes
hooks not flowing) both stalled on the same thing — without runtime
visibility into which events HookServer accepted with what source /
session_id, all triage was guessing. Add a 100-entry in-memory ring
that captures the post-merge view of every dispatched hook event,
exported to state/hook-events.json on diagnostics export.

Per event we keep just the fields needed to reconstruct routing
decisions: timestamp, source, sessionId (truncated), eventName,
toolName, and viaPlugin (wxtsky#123). Payloads stay out of the export so
bug reports remain a sane size.

Mechanism:
- AppState gains a nested DiagnosticHookEvent struct, an
  ObservationIgnored ring buffer, and a recordHookEvent(...) entry.
  Capped at 100 — older entries trimmed FIFO.
- HookServer.processRequest records right after HookEvent
  construction (so merged session_ids are reflected) and before the
  routeKind switch.
- DiagnosticsExporter adds a state/hook-events.json step alongside
  the existing sessions.json snapshot.

Refs wxtsky#103, wxtsky#117.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: harden UserPromptSubmit prompt extraction + richer hook diagnostics (wxtsky#103)

Two changes both aimed at the qwen "prompt not showing" symptom:

1. Reducer prompt extraction
- Switch UserPromptSubmit's prompt extraction to firstStringFromEvent so
  it walks the same key list across both top-level and nested `payload`
  / `data` containers that the rest of the reducer already supports.
- Add `userPrompt` / `text` to the candidate keys.
- The helper trims and skips empty/whitespace-only values, so a hook
  that fires with `"prompt": ""` no longer inserts a blank chat row
  that pushes the rest of the UI around.

2. Hook ring buffer (wxtsky#103 follow-up)
- DiagnosticHookEvent now also captures `payloadKeys` (top-level field
  names with bridge-injected `_*` filtered out) and `promptPreview`
  (first 80 chars of any extracted prompt).
- HookServer computes both right after HookEvent construction and
  passes them into recordHookEvent.
- DiagnosticsExporter writes them into state/hook-events.json.

Together these answer the question that's blocked qwen / hermes /
antigravity triage: when a user reports "my prompt isn't showing",
exporting diagnostics now tells us at a glance whether (a) the hook
didn't fire at all (no entry), (b) it fired without a prompt field
(payloadKeys lists everything but `prompt` and promptPreview is null),
or (c) it fired with a prompt but the UI still dropped it
(promptPreview shows the text and we know to look at the SwiftUI path).

Refs wxtsky#103, wxtsky#117.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: distinguish cursor-agent / qodercli from desktop IDE source (wxtsky#134)

cursor-agent and qodercli share their hooks file (~/.cursor/hooks.json,
~/.qoder/settings.json) with the matching desktop IDE, so the bridge
hook command is identical for both. Until now everything fired through
that file landed as `--source cursor` / `--source qoder`, with the
session displayed as "Cursor" / "Qoder" — confusing for terminal users
who never opened the IDE.

Add `cursor-cli` and `qoder-cli` as first-class sources:

- SessionSnapshot.supportedSources / normalizedSupportedSource gain the
  two new keys plus aliases ("cursor-agent", "cursoragent",
  "cursorcli", "qodercli").
- sourceLabel renders them as "Cursor CLI" / "Qoder CLI".
- ESP32Protocol.MascotID.init reuses the existing .cursor / .qoder
  mascot slots — Buddy firmware only has 16 mascot slots and the
  visual identity is the same. (No firmware change required.)
- CLIProcessResolver.sourceMatchesExecutablePath learns to recognize
  cursor-agent (`/.local/share/cursor-agent/...` or `/cursor-agent/index.js`)
  and qodercli (`/qodercli` or `/@qoder-ai/qodercli`).
- New `cliVariantOverride(declaredSource:ancestry:)` promotes a
  `--source cursor` tag to "cursor-cli" when the ancestry has a
  cursor-agent binary (same for qoder → qoder-cli). The bridge calls
  it after sourceTag/inferSource so explicit tags also get promoted.
- inferSource scans `-cli` variants before plain ones so a path
  ending in `cursor-agent` doesn't get mis-attributed to `cursor`.

Terminal-jump routing uses session.termBundleId / termApp which already
points at the user's terminal app, so no TerminalActivator change is
needed — clicking a Cursor CLI session jumps to the host terminal,
not to Cursor.app.

Existing users need to Reinstall Hooks once so the rebuilt bridge ships
with cliVariantOverride.

Refs wxtsky#134.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: review-driven hardening of session changes

Three issues surfaced by independent code review against the just-landed
batch (ProcessRunner / HookServer plugin filter / subagent tooltip):

1. ProcessRunner data race (Swift 6 strict-concurrency)
   `var data` mutated inside a Sendable closure and read on the calling
   thread compiled cleanly under Swift 5 mode but Swift 6 will flag the
   captured-var write. Wrap the slot in a private `@unchecked Sendable`
   reference cell so the synchronization (drained.signal happens-before
   drained.wait) is explicit.

2. HookServer plugin filter ran JSONSerialization on every hook event
   The pre-filter for `_via_plugin` was parsing every incoming payload
   on the main actor — wasteful for the common case where the marker
   isn't there. Probe the raw bytes for the literal `_via_plugin`
   string first; only fall through to JSON parsing on a hit.

3. Subagent tooltip dropped `toolDescription`
   The previous commit's message advertised "Explore — Read src/main.swift"
   but the actual code only rendered the bare tool name. Fold
   `sub.toolDescription` into the detail when present and replace the
   force-unwrap with a flatMap guard while we're in there.

Refs wxtsky#103, wxtsky#123, wxtsky#139, wxtsky#141.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: clean up remaining review nits from session

Five smaller follow-ups from the same review pass — all flagged minor
or nit, but the ask was to clear the list, not pick:

1. cachedClaudeVersion race (NSLock)
   `detectClaudeVersion` is now reachable from two `Task.detached`
   call sites (wxtsky#139 install + verifyAndRepair). The static cache
   field was unguarded. Wrap read+write in an NSLock — writes are
   idempotent so functional impact was nil, but Swift 6 strict
   concurrency would flag it and a future refactor could turn the
   benign race into a real one.

2. firstStringFromDict no longer trims caller-visible value
   The reducer helper used to return `value.trimmingCharacters(...)`
   which silently stripped intentional leading / trailing whitespace
   from code-snippet prompts. Switch the trim to be empty-check-only
   and return the original value so chat history preserves the user's
   exact text.

3. Diagnostics export description (en / zh / ja / ko / tr)
   `state/hook-events.json` includes a 80-char prompt preview. The
   button description didn't say so. Update all five locales to spell
   out what the zip contains so users aren't surprised by including
   prompt text in a bug report.

4. findSessionId 5-min activity window
   Plugin merge previously matched any session sharing source +
   cliPid, including sessions whose CLI long since exited and whose
   PID may have been recycled by macOS for an unrelated process.
   Require lastActivity within 5 minutes — live sessions update on
   every event so the window is generous.

5. activateGhostty avoids double dispatch
   `activateGhostty` body already runs inside `DispatchQueue.global`
   (wxtsky#139). It then called `runOsaScript` which dispatched a second
   time. Add a `runOsaScriptSync` variant for callers already on a
   background queue and use it from activateGhostty.

Refs wxtsky#103, wxtsky#123, wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: empty default autoApproveTools

Previous default silently auto-approved 9 internal agent tools
(TaskCreate / TaskUpdate / TaskGet / TaskList / TaskOutput / TaskStop
/ TodoRead / TodoWrite / EnterPlanMode), which hid those calls from
the approval panel — users who never opened Settings → Auto-Approve
Tools didn't know that hiding was happening.

Switch the default to the empty string. Every tool call now flows
through the regular approval path; users who want the old behavior
can opt-in per tool from the Auto-Approve Tools list.

Existing users who'd already toggled the list explicitly are
unaffected — UserDefaults register only fills in absent keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf: extract subagent tooltip builder out of ViewBuilder

User reported the hover-triggered island expand animation felt
janky after the recent batch landed. The most plausible regression
was the subagent ForEach in wxtsky#141 — the review fix in be9231a
swelled the inline tooltip computation to five nested `let`s plus
an IIFE plus `flatMap` plus `.map`. That kind of expression in a
SwiftUI ViewBuilder triggers slow-path type inference and forces
the body to re-evaluate the whole closure tree on every hover
state flip / animation tick.

Move the logic to a file-private `subagentTooltipText(_:)` plain
Swift function. The ForEach body is now just two lines:
`MiniAgentIcon(...).help(subagentTooltipText(sub))`. Output is
identical to the previous version.

Refs wxtsky#141.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* release: v1.0.24

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: appcast.xml entry for v1.0.24

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* build: codesign DMG container before notarization

build-dmg.sh was creating an unsigned DMG container — the inner .app
got Developer ID signed and the dmg got stapled with a notary ticket,
but the dmg envelope itself had no signature. \`spctl --assess\` reports
"no usable signature" in that state, and Sparkle's update flow can
abort with "An error occurred while running the updater" when the
helper hand-off picks up the unsigned container.

Sign the DMG with the same Developer ID identity (timestamped) before
submitting to notarytool. The signature applies before notarization,
so the Apple ticket attaches to the signed container.

This is a latent issue — both v1.0.23 and v1.0.24 dmgs ship unsigned
containers; the symptom was intermittent because most code paths only
verify the inner .app + the staple ticket. v1.0.25+ won't have it.

Workaround for users hitting the error on v1.0.24: download the dmg
manually from the GitHub release page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: stop blanket-draining pending permissions on activity events (wxtsky#147)

handleEvent's wasWaiting branch used to drain the entire permission
queue for a session whenever an activity event arrived in
waitingApproval/waitingQuestion. The "user replied in terminal"
heuristic misfires for parallel MCP / plugin tool calls: e.g. the
official Notion plugin fetching 2 pages in parallel — the first
PostToolUse arrives while the second tool's PermissionRequest is
still pending, the blanket drain denies it, and the user sees
"briefly shown then auto-denied".

Switch to surgical drain only — resolveToolUseIfCompleted already
removes queue entries by tool_use_id when PostToolUse /
PostToolUseFailure / PermissionDenied for the same id arrives. Other
pending requests are unrelated parallel work and must keep waiting.

Question queue still gets drained (questions are rare and don't
carry tool_use_id reliably), and session status only resets to
idle/processing when nothing is left pending for the session.

Also threads a `reason:` argument through drainPermissions /
drainQuestions and adds log.notice traces at every auto-deny site
(resolveToolUseIfCompleted, mergeDuplicatePermissionRequest,
drainPermissions/Questions internal). Surfaced as "⚠️ permission
deny reason=… session=… toolUseId=… tool=…" in com.codeisland
subsystem so future "card flashed and disappeared" reports can be
diagnosed from Console.app without a custom build.

Tests: add 2 wxtsky#147 regressions
(testStopEventDoesNotDenyPendingPermission,
testParallelPostToolUseDoesNotDenyUnrelatedPendingPermission).
241/241 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: collapse Cursor sub-agent processes onto one session card (wxtsky#148)

Cursor IDE spawns N parallel sub-agent subprocesses per logical session
(file search, code analysis, etc.), and each sub-agent runs its own
hook subprocess with a different immediate ppid. Bridge's session_id
fallback used `cursor-ppid-<getppid()>`, fanning each sub-agent into a
separate session card — users saw 10+ "Cursor — thinking" cards for
what was logically one session, drowning out the actual session they
were interacting with.

Add CLIProcessResolver.resolvedSessionPID — same shape as
resolvedTrackedPID but picks the *root-most* same-source binary in the
ancestry instead of the nearest. Bridge's session_id fallback now uses
it, so all sub-agents spawned by the same root cursor-agent collapse
onto a single session_id.

To call resolvedSessionPID at fallback time we need ancestry already
computed, so the ancestry/effectiveSource block was moved up in
main.swift to run before the session_id fallback. Same code, earlier
position — `_ppid` / `_via_plugin` / `_source` semantics unchanged.

resolvedTrackedPID still uses `first(where:)` (nearest binary) because
its job — telling AppState which PID to monitor for liveness — wants
the closest CLI process, not the root one.

Tests: new CLIProcessResolverTests covering parallel sub-agents
collapsing to the same root PID, root-most vs nearest distinction
between the two resolvers, and fallbacks (no match, empty ancestry,
nil source, non-positive immediate ppid). 247/247 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: honor user default mascot whenever no session is actively working (wxtsky#149)

wxtsky#102 fixed the empty-session case (no sessions → use default mascot),
but `refreshDerivedState` and `CompactLeftWing.displaySource` still
echoed `deriveSessionSummary`'s `mostRecentIdleSource` whenever any
session existed — even all-idle ones. Result: a user who picked Codex
as the default mascot still saw Claude every time their last session
went idle, because the most recently active session's source kept
"sticking" to the notch.

Switch the trigger from `totalSessionCount == 0` to `summary.status ==
.idle`, covering both empty-state (wxtsky#102) and all-idle (wxtsky#149) under
one rule. Active work (running / processing / waitingApproval /
waitingQuestion) still wins — we don't want to mute the source of
something actively happening just because the user has a preferred
idle mascot.

NotchPanelView's `CompactLeftWing.displaySource` mirrors the same
logic so the compact wing renders the user default in idle even
when the picked-out displaySession happens to be an idle one.

Tests: 4 new regressions in AppStatePrimarySourceTests
(idle-honors-default, active-overrides-default, empty-honors-default,
mixed-active-and-idle-uses-active). 251/251 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Arda Kılıçdağı <ardakilicdagi@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: jerry0804 <jerryzhao0804@gmail.com>
Co-authored-by: JZ <jz@JZdeMacBook-Pro.local>
Co-authored-by: Drswith <49299002+Drswith@users.noreply.github.com>
Co-authored-by: K0ala <liushaoxiong10@outlook.com>
Co-authored-by: wxtsky <1970550145@qq.com>
Uncle-Peke added a commit to Uncle-Peke/CodeIsland that referenced this pull request May 15, 2026
* chore: polish Turkish translation for stylistic consistency (wxtsky#135)

Align 4 post-PR entries with the established style (Title Case for
setting labels, ALL CAPS for action buttons, correct semantics):

- auto_collapse_after_session_jump: lowercase → Title Case
- dismiss: ERTELE (postpone) → YOKSAY (ignore/dismiss)
- buddy_reconnect: sentence case → Title Case
- buddy_enable_bluetooth: sentence case → Title Case

Key parity unchanged (259/259 keys covered).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: prevent Sparkle crash in DEBUG mode when running without bundle ID (wxtsky#133)

Co-authored-by: JZ <jz@JZdeMacBook-Pro.local>

* fix: avoid stale update abort errors (wxtsky#138)

* feat: improve Buddy watch approval previews and alerts (wxtsky#144)

Show the full Bash approval command alongside the human-readable description so pending approvals stay understandable on macOS and on the watch. Preserve pending TraeCLI approvals until the user responds, add watch-side approve/deny and skip controls over BLE, and trigger vibration plus richer attention notifications when approval is required.

* test: align AppStateQuestionFlow assertions with wxtsky#144 toolDescription change

wxtsky#144 changed `HookEvent.toolDescription` for Bash to combine description and
command (so ESP32/watch previews show the full command). Update the existing
two assertions to match the new format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: add ProcessRunner with timeout to harden detectClaudeVersion (wxtsky#139)

Replace the unbounded waitUntilExit() in detectClaudeVersion with a shared
ProcessRunner that enforces a hard deadline (SIGTERM, then SIGKILL after 1s
grace) and drains stdout off the wait path so a full pipe buffer cannot
wedge the child. detectClaudeVersion now uses a 5s timeout — a stuck
`claude --version` used to freeze app launch silently.

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: move launch-time hook installation off the main thread (wxtsky#139)

ConfigInstaller.install() does subprocess version detection plus disk I/O
across multiple CLI configs. Running it inline from
applicationDidFinishLaunching meant a slow CLI binary or network home dir
froze the menu bar / panel until install finished — sometimes silently,
because macOS doesn't pop a "Not Responding" dialog for status items.

Detach it onto a userInitiated Task so app UI comes up immediately while
hooks install in the background. HookServer is still started synchronously
before the detach so the socket is listening by the time settings.json is
rewritten (preserves the ordering noted in the existing comment).

Mark AppDelegate.log nonisolated so the detached task can use it without
violating Swift 6 actor isolation (Logger is Sendable).

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: run periodic hook verifyAndRepair off the main thread (wxtsky#139)

checkAndRepairHooks fires from a 300s timer AND every NSWorkspace
didActivateApplicationNotification (i.e. each time the user app-switches).
verifyAndRepair walks every enabled CLI and rewrites settings.json — on a
network home dir or a slow disk that's enough to stutter the panel.

Keep the 60s debounce on the main actor (cheap), but do the actual repair
on a background task. Repair results only feed a log line so no UI thread
hand-off is needed.

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: cap subprocess waits in TerminalActivator/VisibilityDetector (wxtsky#139)

Both files reimplemented the same Process+Pipe+waitUntilExit pattern with
no timeout. Activate() and the visibility checks fire from the main
thread (panel buttons, shortcut handler, NotchPanelView body, etc.), so a
stuck osascript / tmux / iTerm CLI was free to freeze the UI for as long
as the child took to wake up.

Replace both runProcess implementations with ProcessRunner.run:

- TerminalActivator: 10s cap (matches current osascript-tab-switch
  worst-case behaviour; activateCmux still dispatches off-main on its
  own, the timeout is just a backstop).
- TerminalVisibilityDetector: 5s cap (these are speculative "is the tab
  visible?" probes called frequently from view bodies — fail fast).

Removes ~30 lines of duplicated boilerplate. No behaviour change on the
happy path; on a hang, callers see nil instead of a frozen UI.

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: dispatch Ghostty activation off the main thread (wxtsky#139)

activateGhostty was the last activator that still resolved its
subprocess inputs synchronously on the main thread. It calls
`tmux display-message` to fetch a session/window key (used to match
the right Ghostty terminal in the AppleScript template) and then
hands the script off to runOsaScript. Every other activateXxx already
runs on a background queue (activateITerm/Warp/WezTerm/Kitty/Tmux/IDE
window via runAppleScript or an explicit DispatchQueue.global; cmux
has its own dispatch).

Move the same pattern here: keep the AppKit gating (NSWorkspace
runningApplications + unhide) on the main thread where it has to live,
then dispatch the tmux probe, AppleScript construction, and
osascript invocation onto a userInitiated queue. With this change no
TerminalActivator code path can hold the main thread on a stuck
subprocess, which closes the loop on issue wxtsky#139.

Refs wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: register Codex PermissionRequest hook so approvals trigger UI + sound (wxtsky#145)

Codex CLI supports a PermissionRequest hook event (per
developers.openai.com/codex/hooks) that fires when Codex needs user
approval — shell escalation, managed-network access, etc. CodeIsland's
Codex CLIConfig only registered SessionStart/End, UserPromptSubmit,
PreToolUse, PostToolUse, and Stop, so this event was never installed
into Codex's hooks.json.

Result: when Codex hit an approval gate, the panel stayed in "running"
status forever, no approval card opened, and SoundManager wasn't
triggered (handlePermissionRequest is only called from the
PermissionRequest event path).

Add the event with the same shape as Claude's: 24h timeout, blocking
(async=false) so Codex waits for the user's allow/deny decision.

Existing users need to hit Settings → Reinstall Hooks once to rewrite
~/.codex/hooks.json with the new entry; new installs pick it up
automatically.

Refs wxtsky#145.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: setting to disable auto-expand on agent completion (wxtsky#146)

Adds Settings → Behavior → "Auto-Expand Panel on Agent Completion"
(default On — preserves current behavior). When off, CodeIsland stays
in its compact state when agents/subagents finish; status indicators,
the queue, and manual interactions are unaffected.

Implementation:
- New SettingsKey/SettingsDefaults entry registered with default true
  so existing users keep the current behavior across upgrades.
- AppState.enqueueCompletion early-returns when the toggle is off.
  This is the single chokepoint — both Stop events from the reducer
  and any other paths into completion display funnel through here, so
  one guard covers all entry points.
- BehaviorToggleRow added next to the existing auto-collapse toggle,
  reusing the .smartSuppress animation icon (semantically the closest
  "suppress auto-expand" preview we already have).
- Translations added for en / zh / ja / ko / tr.

Refs wxtsky#146.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: prefer opencode.jsonc over opencode.json when registering plugin (wxtsky#132)

OpenCode's troubleshooting guide recommends opencode.jsonc (with-comments
JSON) as the canonical config filename. Before this change, CodeIsland
only ever read/wrote ~/.config/opencode/opencode.json, so users on
opencode.jsonc would see CodeIsland resurrect a sibling opencode.json
on every install / repair pass.

Pick the target by precedence at install time:
1. opencode.jsonc — write here when it already exists (OpenCode-recommended)
2. opencode.json   — fallback for existing setups, also the default for fresh installs
3. config.json     — legacy, still cleaned up after migration

uninstall and isOpencodePluginInstalled now scan all three. The existing
mergeOpencodePluginRef path already runs through stripJSONComments, so
.jsonc input parses correctly without changes.

Refs wxtsky#132.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: surface subagent count + agent type tooltip on session card (wxtsky#141)

Subagents already rendered as 8px MiniAgentIcons under the mascot but
were visually identical and unlabelled — users couldn't tell at a glance
which sessions had spawned background work, or what each subagent was
actually doing.

Two additions, no layout changes:

1. Each MiniAgentIcon now carries a .help(...) tooltip that surfaces
   the agent type and current tool (e.g. "Explore — Read src/main.swift",
   or just "general-purpose" when no tool is active).
2. A new SessionTag "+N Sub" (purple) appears in the session header tag
   row alongside @Remote / INT / YOLO when session.subagents is
   non-empty — gives the at-a-glance count the issue asked for without
   touching the column-1 mascot/icon block.

Refs wxtsky#141.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: Settings → Plugin Sub-Sessions: separate / merge / hide (wxtsky#123)

When a plugin running inside another agent (e.g. omo inside OpenCode)
fires its own hook events without an explicit --source, bridge already
infers the real source via process ancestry (wxtsky#95). The plugin's events
end up with the right source but a different session_id from the main
session, so the user sees two same-source pets — confusing for users
who think of the plugin as part of the main session.

Add a 3-way Settings toggle to control how those events render:

- separate (default, current behavior): plugin events keep their own
  session_id, render as their own card.
- merge: rewrite session_id to the matching main session (same source,
  same _ppid). Plugin events fold into the parent agent's card.
- hide: drop plugin events entirely; PermissionRequest gets an auto-allow
  so plugin tool execution isn't blocked by a UI prompt the user
  asked to suppress.

Implementation:

- Bridge: stamp `_via_plugin = true` when sourceTag is nil but
  inferSource succeeded (this is exactly the "plugin proxy" path).
- HookServer.processRequest: pre-filter `_via_plugin` events per the
  pluginSessionMode setting before HookEvent construction. Merge looks
  up AppState.findSessionId(forSource:ppid:) using SessionSnapshot.cliPid
  (which already mirrors bridge's _ppid). Hide returns the right
  payload for blocking events.
- Settings + SettingsView: new pluginSessionMode key (default "separate"
  preserves existing behavior), Picker added to the Sessions section.
- L10n: en / zh / ja / ko / tr translations for title, desc, and the
  three options.

Existing users will need to Reinstall Hooks once so the new bridge
binary stamps `_via_plugin`. Without the new bridge, all paths behave
exactly as before (separate).

Refs wxtsky#123.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: hook event ring buffer + diagnostics export (wxtsky#103)

Issue wxtsky#103 (qwen problem 1: prompts not surfacing) and wxtsky#117 (Hermes
hooks not flowing) both stalled on the same thing — without runtime
visibility into which events HookServer accepted with what source /
session_id, all triage was guessing. Add a 100-entry in-memory ring
that captures the post-merge view of every dispatched hook event,
exported to state/hook-events.json on diagnostics export.

Per event we keep just the fields needed to reconstruct routing
decisions: timestamp, source, sessionId (truncated), eventName,
toolName, and viaPlugin (wxtsky#123). Payloads stay out of the export so
bug reports remain a sane size.

Mechanism:
- AppState gains a nested DiagnosticHookEvent struct, an
  ObservationIgnored ring buffer, and a recordHookEvent(...) entry.
  Capped at 100 — older entries trimmed FIFO.
- HookServer.processRequest records right after HookEvent
  construction (so merged session_ids are reflected) and before the
  routeKind switch.
- DiagnosticsExporter adds a state/hook-events.json step alongside
  the existing sessions.json snapshot.

Refs wxtsky#103, wxtsky#117.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: harden UserPromptSubmit prompt extraction + richer hook diagnostics (wxtsky#103)

Two changes both aimed at the qwen "prompt not showing" symptom:

1. Reducer prompt extraction
- Switch UserPromptSubmit's prompt extraction to firstStringFromEvent so
  it walks the same key list across both top-level and nested `payload`
  / `data` containers that the rest of the reducer already supports.
- Add `userPrompt` / `text` to the candidate keys.
- The helper trims and skips empty/whitespace-only values, so a hook
  that fires with `"prompt": ""` no longer inserts a blank chat row
  that pushes the rest of the UI around.

2. Hook ring buffer (wxtsky#103 follow-up)
- DiagnosticHookEvent now also captures `payloadKeys` (top-level field
  names with bridge-injected `_*` filtered out) and `promptPreview`
  (first 80 chars of any extracted prompt).
- HookServer computes both right after HookEvent construction and
  passes them into recordHookEvent.
- DiagnosticsExporter writes them into state/hook-events.json.

Together these answer the question that's blocked qwen / hermes /
antigravity triage: when a user reports "my prompt isn't showing",
exporting diagnostics now tells us at a glance whether (a) the hook
didn't fire at all (no entry), (b) it fired without a prompt field
(payloadKeys lists everything but `prompt` and promptPreview is null),
or (c) it fired with a prompt but the UI still dropped it
(promptPreview shows the text and we know to look at the SwiftUI path).

Refs wxtsky#103, wxtsky#117.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: distinguish cursor-agent / qodercli from desktop IDE source (wxtsky#134)

cursor-agent and qodercli share their hooks file (~/.cursor/hooks.json,
~/.qoder/settings.json) with the matching desktop IDE, so the bridge
hook command is identical for both. Until now everything fired through
that file landed as `--source cursor` / `--source qoder`, with the
session displayed as "Cursor" / "Qoder" — confusing for terminal users
who never opened the IDE.

Add `cursor-cli` and `qoder-cli` as first-class sources:

- SessionSnapshot.supportedSources / normalizedSupportedSource gain the
  two new keys plus aliases ("cursor-agent", "cursoragent",
  "cursorcli", "qodercli").
- sourceLabel renders them as "Cursor CLI" / "Qoder CLI".
- ESP32Protocol.MascotID.init reuses the existing .cursor / .qoder
  mascot slots — Buddy firmware only has 16 mascot slots and the
  visual identity is the same. (No firmware change required.)
- CLIProcessResolver.sourceMatchesExecutablePath learns to recognize
  cursor-agent (`/.local/share/cursor-agent/...` or `/cursor-agent/index.js`)
  and qodercli (`/qodercli` or `/@qoder-ai/qodercli`).
- New `cliVariantOverride(declaredSource:ancestry:)` promotes a
  `--source cursor` tag to "cursor-cli" when the ancestry has a
  cursor-agent binary (same for qoder → qoder-cli). The bridge calls
  it after sourceTag/inferSource so explicit tags also get promoted.
- inferSource scans `-cli` variants before plain ones so a path
  ending in `cursor-agent` doesn't get mis-attributed to `cursor`.

Terminal-jump routing uses session.termBundleId / termApp which already
points at the user's terminal app, so no TerminalActivator change is
needed — clicking a Cursor CLI session jumps to the host terminal,
not to Cursor.app.

Existing users need to Reinstall Hooks once so the rebuilt bridge ships
with cliVariantOverride.

Refs wxtsky#134.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: review-driven hardening of session changes

Three issues surfaced by independent code review against the just-landed
batch (ProcessRunner / HookServer plugin filter / subagent tooltip):

1. ProcessRunner data race (Swift 6 strict-concurrency)
   `var data` mutated inside a Sendable closure and read on the calling
   thread compiled cleanly under Swift 5 mode but Swift 6 will flag the
   captured-var write. Wrap the slot in a private `@unchecked Sendable`
   reference cell so the synchronization (drained.signal happens-before
   drained.wait) is explicit.

2. HookServer plugin filter ran JSONSerialization on every hook event
   The pre-filter for `_via_plugin` was parsing every incoming payload
   on the main actor — wasteful for the common case where the marker
   isn't there. Probe the raw bytes for the literal `_via_plugin`
   string first; only fall through to JSON parsing on a hit.

3. Subagent tooltip dropped `toolDescription`
   The previous commit's message advertised "Explore — Read src/main.swift"
   but the actual code only rendered the bare tool name. Fold
   `sub.toolDescription` into the detail when present and replace the
   force-unwrap with a flatMap guard while we're in there.

Refs wxtsky#103, wxtsky#123, wxtsky#139, wxtsky#141.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: clean up remaining review nits from session

Five smaller follow-ups from the same review pass — all flagged minor
or nit, but the ask was to clear the list, not pick:

1. cachedClaudeVersion race (NSLock)
   `detectClaudeVersion` is now reachable from two `Task.detached`
   call sites (wxtsky#139 install + verifyAndRepair). The static cache
   field was unguarded. Wrap read+write in an NSLock — writes are
   idempotent so functional impact was nil, but Swift 6 strict
   concurrency would flag it and a future refactor could turn the
   benign race into a real one.

2. firstStringFromDict no longer trims caller-visible value
   The reducer helper used to return `value.trimmingCharacters(...)`
   which silently stripped intentional leading / trailing whitespace
   from code-snippet prompts. Switch the trim to be empty-check-only
   and return the original value so chat history preserves the user's
   exact text.

3. Diagnostics export description (en / zh / ja / ko / tr)
   `state/hook-events.json` includes a 80-char prompt preview. The
   button description didn't say so. Update all five locales to spell
   out what the zip contains so users aren't surprised by including
   prompt text in a bug report.

4. findSessionId 5-min activity window
   Plugin merge previously matched any session sharing source +
   cliPid, including sessions whose CLI long since exited and whose
   PID may have been recycled by macOS for an unrelated process.
   Require lastActivity within 5 minutes — live sessions update on
   every event so the window is generous.

5. activateGhostty avoids double dispatch
   `activateGhostty` body already runs inside `DispatchQueue.global`
   (wxtsky#139). It then called `runOsaScript` which dispatched a second
   time. Add a `runOsaScriptSync` variant for callers already on a
   background queue and use it from activateGhostty.

Refs wxtsky#103, wxtsky#123, wxtsky#139.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: empty default autoApproveTools

Previous default silently auto-approved 9 internal agent tools
(TaskCreate / TaskUpdate / TaskGet / TaskList / TaskOutput / TaskStop
/ TodoRead / TodoWrite / EnterPlanMode), which hid those calls from
the approval panel — users who never opened Settings → Auto-Approve
Tools didn't know that hiding was happening.

Switch the default to the empty string. Every tool call now flows
through the regular approval path; users who want the old behavior
can opt-in per tool from the Auto-Approve Tools list.

Existing users who'd already toggled the list explicitly are
unaffected — UserDefaults register only fills in absent keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf: extract subagent tooltip builder out of ViewBuilder

User reported the hover-triggered island expand animation felt
janky after the recent batch landed. The most plausible regression
was the subagent ForEach in wxtsky#141 — the review fix in be9231a
swelled the inline tooltip computation to five nested `let`s plus
an IIFE plus `flatMap` plus `.map`. That kind of expression in a
SwiftUI ViewBuilder triggers slow-path type inference and forces
the body to re-evaluate the whole closure tree on every hover
state flip / animation tick.

Move the logic to a file-private `subagentTooltipText(_:)` plain
Swift function. The ForEach body is now just two lines:
`MiniAgentIcon(...).help(subagentTooltipText(sub))`. Output is
identical to the previous version.

Refs wxtsky#141.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* release: v1.0.24

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: appcast.xml entry for v1.0.24

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* build: codesign DMG container before notarization

build-dmg.sh was creating an unsigned DMG container — the inner .app
got Developer ID signed and the dmg got stapled with a notary ticket,
but the dmg envelope itself had no signature. \`spctl --assess\` reports
"no usable signature" in that state, and Sparkle's update flow can
abort with "An error occurred while running the updater" when the
helper hand-off picks up the unsigned container.

Sign the DMG with the same Developer ID identity (timestamped) before
submitting to notarytool. The signature applies before notarization,
so the Apple ticket attaches to the signed container.

This is a latent issue — both v1.0.23 and v1.0.24 dmgs ship unsigned
containers; the symptom was intermittent because most code paths only
verify the inner .app + the staple ticket. v1.0.25+ won't have it.

Workaround for users hitting the error on v1.0.24: download the dmg
manually from the GitHub release page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: stop blanket-draining pending permissions on activity events (wxtsky#147)

handleEvent's wasWaiting branch used to drain the entire permission
queue for a session whenever an activity event arrived in
waitingApproval/waitingQuestion. The "user replied in terminal"
heuristic misfires for parallel MCP / plugin tool calls: e.g. the
official Notion plugin fetching 2 pages in parallel — the first
PostToolUse arrives while the second tool's PermissionRequest is
still pending, the blanket drain denies it, and the user sees
"briefly shown then auto-denied".

Switch to surgical drain only — resolveToolUseIfCompleted already
removes queue entries by tool_use_id when PostToolUse /
PostToolUseFailure / PermissionDenied for the same id arrives. Other
pending requests are unrelated parallel work and must keep waiting.

Question queue still gets drained (questions are rare and don't
carry tool_use_id reliably), and session status only resets to
idle/processing when nothing is left pending for the session.

Also threads a `reason:` argument through drainPermissions /
drainQuestions and adds log.notice traces at every auto-deny site
(resolveToolUseIfCompleted, mergeDuplicatePermissionRequest,
drainPermissions/Questions internal). Surfaced as "⚠️ permission
deny reason=… session=… toolUseId=… tool=…" in com.codeisland
subsystem so future "card flashed and disappeared" reports can be
diagnosed from Console.app without a custom build.

Tests: add 2 wxtsky#147 regressions
(testStopEventDoesNotDenyPendingPermission,
testParallelPostToolUseDoesNotDenyUnrelatedPendingPermission).
241/241 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: collapse Cursor sub-agent processes onto one session card (wxtsky#148)

Cursor IDE spawns N parallel sub-agent subprocesses per logical session
(file search, code analysis, etc.), and each sub-agent runs its own
hook subprocess with a different immediate ppid. Bridge's session_id
fallback used `cursor-ppid-<getppid()>`, fanning each sub-agent into a
separate session card — users saw 10+ "Cursor — thinking" cards for
what was logically one session, drowning out the actual session they
were interacting with.

Add CLIProcessResolver.resolvedSessionPID — same shape as
resolvedTrackedPID but picks the *root-most* same-source binary in the
ancestry instead of the nearest. Bridge's session_id fallback now uses
it, so all sub-agents spawned by the same root cursor-agent collapse
onto a single session_id.

To call resolvedSessionPID at fallback time we need ancestry already
computed, so the ancestry/effectiveSource block was moved up in
main.swift to run before the session_id fallback. Same code, earlier
position — `_ppid` / `_via_plugin` / `_source` semantics unchanged.

resolvedTrackedPID still uses `first(where:)` (nearest binary) because
its job — telling AppState which PID to monitor for liveness — wants
the closest CLI process, not the root one.

Tests: new CLIProcessResolverTests covering parallel sub-agents
collapsing to the same root PID, root-most vs nearest distinction
between the two resolvers, and fallbacks (no match, empty ancestry,
nil source, non-positive immediate ppid). 247/247 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: honor user default mascot whenever no session is actively working (wxtsky#149)

wxtsky#102 fixed the empty-session case (no sessions → use default mascot),
but `refreshDerivedState` and `CompactLeftWing.displaySource` still
echoed `deriveSessionSummary`'s `mostRecentIdleSource` whenever any
session existed — even all-idle ones. Result: a user who picked Codex
as the default mascot still saw Claude every time their last session
went idle, because the most recently active session's source kept
"sticking" to the notch.

Switch the trigger from `totalSessionCount == 0` to `summary.status ==
.idle`, covering both empty-state (wxtsky#102) and all-idle (wxtsky#149) under
one rule. Active work (running / processing / waitingApproval /
waitingQuestion) still wins — we don't want to mute the source of
something actively happening just because the user has a preferred
idle mascot.

NotchPanelView's `CompactLeftWing.displaySource` mirrors the same
logic so the compact wing renders the user default in idle even
when the picked-out displaySession happens to be an idle one.

Tests: 4 new regressions in AppStatePrimarySourceTests
(idle-honors-default, active-overrides-default, empty-honors-default,
mixed-active-and-idle-uses-active). 251/251 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Arda Kılıçdağı <ardakilicdagi@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: jerry0804 <jerryzhao0804@gmail.com>
Co-authored-by: JZ <jz@JZdeMacBook-Pro.local>
Co-authored-by: Drswith <49299002+Drswith@users.noreply.github.com>
Co-authored-by: K0ala <liushaoxiong10@outlook.com>
Co-authored-by: wxtsky <1970550145@qq.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

关于页面提示已是最新版本后仍显示更新失败

2 participants