Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
{
"type": "generic",
"path": "panel/Info.plist"
},
{
"type": "generic",
"path": "notify.sh"
}
]
}
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,13 @@ Opt-in GitHub linking adds real **PR + CI status** (so even squash-merged work r
| `STACKNUDGE_TICKET_URL` | Deep-link template for ticket rows, e.g. `https://linear.app/acme/issue/{key}` — `{key}` is replaced with the ticket |
| `STACKNUDGE_HIDE_SHIPPED` | `true` to drop groups once their PR reads merged, keeping the tab on in-flight work |

**Nothing showing up?** A session is recorded when an agent's turn ends *inside a git repo*, and only if the hook payload carries a session id. The tab's empty state names which of those failed; `~/.stack-nudge/app.log` has a line per dropped turn. The usual cause is an installed hook script older than the app, since updates swap the `.app` alone: the app repairs that on launch, and Settings warns in the footer if the rewrite couldn't be applied. To fix it by hand:

```bash
grep -c stack-nudge-version ~/.stack-nudge/notify.sh # 0 means the script predates v1.26
./install.sh # or reinstall from the app
```

### Menu bar (macOS)

When the panel daemon is running, a bell icon appears in your menu bar. The same items you can reach from the in-panel Settings tab are mirrored here for one-click access without summoning the panel:
Expand Down Expand Up @@ -283,6 +290,8 @@ stack-nudge polls GitHub Releases on launch and every 6 hours. When a newer rele
5. Runs `launchctl kickstart -k` — the current process dies, launchd brings up the new bundle
6. The new bundle's first launch shows a welcome-style "Updated to vX.Y.Z" screen with the release notes

Because the swap replaces only the bundle, the hook script the agents invoke (`~/.stack-nudge/notify.sh`) would otherwise stay at whatever version first installed it. Each launch compares its `stack-nudge-version` stamp against the bundled script's and rewrites it when they differ, so payload fields added by a release reach the panel without a reinstall.

No source clone, no swiftc rebuild on the user's machine — the new bundle is the already-signed-and-notarized artifact from CI. Updates are fast and don't disturb the user's Xcode CLT or Python install (or lack thereof).

While the StackOne stack-nudge repo is private the auto-updater falls back to your local `gh` CLI auth (`gh api`) to read the release metadata. Org members with `gh` configured see no friction; the actual artifact download uses the release's signed asset URL.
Expand Down
170 changes: 170 additions & 0 deletions Tests/StackNudgePanelCoreTests/BootstrapTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import XCTest

@testable import StackNudgePanelCore

final class BootstrapTests: XCTestCase {

// MARK: - notifyVersion(inScript:)

func test_notifyVersion_readsTheStampFromTheHeader() {
let script = """
#!/usr/bin/env bash
# stack-nudge: Cross-platform notifications for AI coding agent hooks
# stack-nudge-version: 1.26.0 # x-release-please-version
AGENT="${1:-agent}"
"""
XCTAssertEqual(Bootstrap.notifyVersion(inScript: script), "1.26.0")
}

func test_notifyVersion_isNilForAScriptWithoutAStamp() {
let script = """
#!/usr/bin/env bash
# stack-nudge: Cross-platform notifications for AI coding agent hooks
AGENT="${1:-agent}"
"""
XCTAssertNil(Bootstrap.notifyVersion(inScript: script))
}

func test_notifyVersion_ignoresAMalformedStamp() {
let script = "#!/usr/bin/env bash\n# stack-nudge-version: v1.26\n"
XCTAssertNil(Bootstrap.notifyVersion(inScript: script))
}

// The stamp is a header comment; a mention further down (a heredoc, a log
// line, a doc block) must not be mistaken for it.
func test_notifyVersion_ignoresMatchesBelowTheHeader() {
let filler = Array(repeating: "# padding", count: 60).joined(separator: "\n")
let script = "#!/usr/bin/env bash\n\(filler)\n# stack-nudge-version: 9.9.9\n"
XCTAssertNil(Bootstrap.notifyVersion(inScript: script))
}

func test_notifyVersion_toleratesNoSpaceAfterTheHash() {
XCTAssertEqual(Bootstrap.notifyVersion(inScript: "#stack-nudge-version:1.2.3"), "1.2.3")
}

// MARK: - needsNotifyRefresh

func test_needsNotifyRefresh_isTrueWhenTheInstalledScriptIsUnstamped() {
// Every install predating the stamp: the drift this repairs.
XCTAssertTrue(Bootstrap.needsNotifyRefresh(installed: nil, bundled: "1.26.0"))
}

func test_needsNotifyRefresh_isTrueWhenTheVersionsDiffer() {
XCTAssertTrue(Bootstrap.needsNotifyRefresh(installed: "1.12.0", bundled: "1.26.0"))
}

// Not just "older": a downgrade to an earlier bundle should also restore the
// script that bundle expects to be talking to.
func test_needsNotifyRefresh_isTrueWhenTheInstalledScriptIsNewer() {
XCTAssertTrue(Bootstrap.needsNotifyRefresh(installed: "1.30.0", bundled: "1.26.0"))
}

func test_needsNotifyRefresh_isFalseWhenTheVersionsMatch() {
XCTAssertFalse(Bootstrap.needsNotifyRefresh(installed: "1.26.0", bundled: "1.26.0"))
}

// An unstamped bundle is a local swiftc build, where the developer's own
// script is the one under test. Leave it alone rather than guess.
func test_needsNotifyRefresh_isFalseWhenTheBundleIsUnstamped() {
XCTAssertFalse(Bootstrap.needsNotifyRefresh(installed: "1.12.0", bundled: nil))
XCTAssertFalse(Bootstrap.needsNotifyRefresh(installed: nil, bundled: nil))
}

// MARK: - writeNotifyScript

func test_writeNotifyScript_writesExecutableContent() throws {
let dir = try temporaryDirectory()
let path = dir.appendingPathComponent("notify.sh").path

try Bootstrap.writeNotifyScript("#!/usr/bin/env bash\necho new\n", to: path)

XCTAssertEqual(try String(contentsOfFile: path, encoding: .utf8),
"#!/usr/bin/env bash\necho new\n")
XCTAssertTrue(FileManager.default.isExecutableFile(atPath: path))
}

func test_writeNotifyScript_replacesAnExistingScript() throws {
let dir = try temporaryDirectory()
let path = dir.appendingPathComponent("notify.sh").path
try "old".write(toFile: path, atomically: true, encoding: .utf8)

try Bootstrap.writeNotifyScript("new", to: path)

XCTAssertEqual(try String(contentsOfFile: path, encoding: .utf8), "new")
XCTAssertTrue(FileManager.default.isExecutableFile(atPath: path))
}

// The swap must never leave the destination absent, since a hook firing
// mid-write would otherwise find no script to run.
func test_writeNotifyScript_leavesNoTempFilesBehind() throws {
let dir = try temporaryDirectory()
let path = dir.appendingPathComponent("notify.sh").path
try "old".write(toFile: path, atomically: true, encoding: .utf8)

try Bootstrap.writeNotifyScript("new", to: path)

let entries = try FileManager.default.contentsOfDirectory(atPath: dir.path)
XCTAssertEqual(entries, ["notify.sh"])
}

func test_writeNotifyScript_throwsWhenTheDirectoryIsMissing() {
let path = "/nonexistent-\(UUID().uuidString)/notify.sh"
XCTAssertThrowsError(try Bootstrap.writeNotifyScript("new", to: path))
}

// MARK: - refreshNotifyScript

private static let stamped = "#!/usr/bin/env bash\n# stack-nudge-version: 1.26.0\necho new\n"

func test_refreshNotifyScript_replacesAnUnstampedInstall() throws {
let dir = try temporaryDirectory()
let path = dir.appendingPathComponent("notify.sh").path
try "#!/usr/bin/env bash\necho old\n".write(toFile: path, atomically: true, encoding: .utf8)

let written = Bootstrap.refreshNotifyScript(bundled: Self.stamped, installedPath: path)

XCTAssertEqual(written, "1.26.0")
XCTAssertEqual(try String(contentsOfFile: path, encoding: .utf8), Self.stamped)
XCTAssertTrue(FileManager.default.isExecutableFile(atPath: path))
}

func test_refreshNotifyScript_leavesAMatchingInstallUntouched() throws {
let dir = try temporaryDirectory()
let path = dir.appendingPathComponent("notify.sh").path
let installed = "#!/usr/bin/env bash\n# stack-nudge-version: 1.26.0\necho mine\n"
try installed.write(toFile: path, atomically: true, encoding: .utf8)

XCTAssertNil(Bootstrap.refreshNotifyScript(bundled: Self.stamped, installedPath: path))
// Same stamp means same protocol, so a locally tweaked script survives.
XCTAssertEqual(try String(contentsOfFile: path, encoding: .utf8), installed)
}

func test_refreshNotifyScript_doesNothingWithoutABundledScript() throws {
let dir = try temporaryDirectory()
let path = dir.appendingPathComponent("notify.sh").path
try "old".write(toFile: path, atomically: true, encoding: .utf8)

XCTAssertNil(Bootstrap.refreshNotifyScript(bundled: nil, installedPath: path))
XCTAssertEqual(try String(contentsOfFile: path, encoding: .utf8), "old")
}

// No installed script means no install to repair; writing one would leave a
// file nothing is wired to invoke.
func test_refreshNotifyScript_doesNotCreateAMissingScript() throws {
let dir = try temporaryDirectory()
let path = dir.appendingPathComponent("notify.sh").path

XCTAssertNil(Bootstrap.refreshNotifyScript(bundled: Self.stamped, installedPath: path))
XCTAssertFalse(FileManager.default.fileExists(atPath: path))
}

// MARK: - Helpers

private func temporaryDirectory() throws -> URL {
let dir = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("bootstrap-tests-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
addTeardownBlock { try? FileManager.default.removeItem(at: dir) }
return dir
}
}
46 changes: 46 additions & 0 deletions Tests/StackNudgePanelCoreTests/OutcomesViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -261,4 +261,50 @@ final class OutcomesViewTests: XCTestCase {
func test_selectedRow_nilWhenNothingToSelect() {
XCTAssertNil(OutcomesView.selectedRow([], index: 0))
}

// MARK: - emptyReason

func test_emptyReason_isNoSessionsOnAnUntouchedLedger() {
XCTAssertEqual(OutcomesView.emptyReason(totalGroups: 0, drops: [:]), .noSessions)
}

func test_emptyReason_reportsFilteredGroupsBeforeDrops() {
// Groups exist but none are visible, so hide-shipped is the explanation
// even when unrelated drops have also been counted.
XCTAssertEqual(
OutcomesView.emptyReason(totalGroups: 3, drops: [.notAGitRepo: 2]),
.allShipped(hidden: 3))
}

func test_emptyReason_reportsTheDominantDropReasonAndTotalCount() {
let reason = OutcomesView.emptyReason(
totalGroups: 0, drops: [.notAGitRepo: 2, .missingSessionID: 7])
XCTAssertEqual(reason, .allDropped(count: 9, reason: .missingSessionID))
}

func test_emptyReason_ignoresZeroedDropCounts() {
XCTAssertEqual(OutcomesView.emptyReason(totalGroups: 0, drops: [.missingSessionID: 0]),
.noSessions)
}

// The actionable case names the remedy; the expected one (no repo) doesn't
// pretend something is broken.
func test_emptyReasonDetail_includesTheRemedyOnlyWhenThereIsOne() {
let broken = OutcomesView.EmptyReason.allDropped(count: 1, reason: .missingSessionID)
XCTAssertTrue(broken.detail.contains("notify.sh"))
let expected = OutcomesView.EmptyReason.allDropped(count: 1, reason: .notAGitRepo)
XCTAssertTrue(expected.detail.contains("git repo"))
XCTAssertFalse(expected.detail.contains("notify.sh"))
}

func test_emptyReasonTitle_singularAndPlural() {
XCTAssertEqual(OutcomesView.EmptyReason.allDropped(count: 1, reason: .notAGitRepo).title,
"1 turn wasn't recorded")
XCTAssertEqual(OutcomesView.EmptyReason.allDropped(count: 4, reason: .notAGitRepo).title,
"4 turns weren't recorded")
XCTAssertEqual(OutcomesView.EmptyReason.allShipped(hidden: 1).title,
"1 shipped ticket hidden")
XCTAssertEqual(OutcomesView.EmptyReason.allShipped(hidden: 2).title,
"2 shipped tickets hidden")
}
}
15 changes: 13 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,21 @@ fi

# Copy notify.sh and the phrase pools (sourced by notify.sh at runtime
# based on the configured voice's language) to the shared install dir.
cp "$SCRIPT_DIR/notify.sh" "$INSTALL_DIR/notify.sh"
chmod +x "$INSTALL_DIR/notify.sh"
#
# Written via a temp file + mv so the swap is atomic: on a reinstall the agents
# are usually live, and a hook firing between rm and cp would find no script.
cp "$SCRIPT_DIR/notify.sh" "$INSTALL_DIR/.notify.sh.new"
chmod +x "$INSTALL_DIR/.notify.sh.new"
mv -f "$INSTALL_DIR/.notify.sh.new" "$INSTALL_DIR/notify.sh"
echo " Installed notify.sh -> $INSTALL_DIR/notify.sh"

# jq is optional: the socket payload is parsed by python3, but the permission
# banner's "what needs approval" detail still reads the hook JSON with jq.
if ! command -v jq >/dev/null 2>&1; then
echo " ! jq not found; permission banners will omit the tool/command detail."
echo " Install it with: brew install jq"
fi

if [[ -d "$SCRIPT_DIR/phrases" ]]; then
rm -rf "$INSTALL_DIR/phrases"
cp -R "$SCRIPT_DIR/phrases" "$INSTALL_DIR/phrases"
Expand Down
44 changes: 40 additions & 4 deletions notify.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
#!/usr/bin/env bash
# stack-nudge: Cross-platform notifications for AI coding agent hooks
#
# The installed copy at ~/.stack-nudge/notify.sh is what agent hooks actually
# invoke, and it is only written by install.sh / the first-launch wizard; app
# updates swap the .app bundle alone. The stamp below is how the app spots a copy
# older than itself and replaces it (Bootstrap.refreshNotifyScriptIfNeeded);
# release-please rewrites it in step with panel/Info.plist. Don't hand-edit it.
# stack-nudge-version: 1.26.0 # x-release-please-version
#
# Usage: notify.sh <agent> <event>
# agent: claude-code | cursor | gemini | codex | <any name>
# event: stop | permission
Expand Down Expand Up @@ -445,6 +453,14 @@ post_to_panel() {
walk_session_chain
detect_iterm_tab_name

# Cap what the exec carries: env and argv share ARG_MAX (1 MiB on macOS), and a
# PermissionRequest payload can hold a whole file's contents in tool_input.
# Overflowing it fails the python3 exec, which would lose the event entirely.
# The fields read from it are a few hundred bytes deep in a Stop payload, so a
# 32 KiB cap never bites for the events that need them.
local hook_json="$HOOK_JSON"
(( ${#hook_json} > 32768 )) && hook_json=""

NUDGE_AGENT="$AGENT" \
NUDGE_EVENT="$EVENT" \
NUDGE_TITLE="$1" \
Expand All @@ -466,12 +482,32 @@ post_to_panel() {
NUDGE_TERM_PROGRAM="${TERM_PROGRAM:-}" \
NUDGE_SESSION_ID="${TERM_SESSION_ID:-${ITERM_SESSION_ID:-}}" \
NUDGE_ITERM_TAB_NAME="${ITERM_TAB_NAME:-}" \
NUDGE_CLAUDE_SESSION_ID="$(command -v jq &>/dev/null && [[ -n "$HOOK_JSON" ]] && printf '%s' "$HOOK_JSON" | jq -r '.session_id // empty' 2>/dev/null || true)" \
NUDGE_TRANSCRIPT_PATH="$(command -v jq &>/dev/null && [[ -n "$HOOK_JSON" ]] && printf '%s' "$HOOK_JSON" | jq -r '.transcript_path // empty' 2>/dev/null || true)" \
NUDGE_HOOK_JSON="$hook_json" \
python3 - <<'PY' 2>/dev/null
import json, os, socket, time

env = os.environ

# session_id / transcript_path come straight out of the hook JSON. Parsed here
# rather than with jq upstream: only macOS 15+ ships /usr/bin/jq, and the old
# `command -v jq && … || true` form degraded to an empty string when it was
# absent, which the panel can't distinguish from "no session". Missing
# claude_session_id silently drops the whole handoff record
# (PanelController.captureHandoff), leaving the Tickets tab empty. python3 is
# already required to reach the socket at all, so parsing here adds no dependency.
def hook_field(name):
raw = env.get("NUDGE_HOOK_JSON") or ""
if not raw:
return None
try:
parsed = json.loads(raw)
except ValueError:
return None
if not isinstance(parsed, dict):
return None
value = parsed.get(name)
return value if isinstance(value, str) and value else None

out = {
"agent": env["NUDGE_AGENT"],
"event": env["NUDGE_EVENT"],
Expand All @@ -496,8 +532,8 @@ optional = {
"term_program": env.get("NUDGE_TERM_PROGRAM"),
"session_id": env.get("NUDGE_SESSION_ID"),
"iterm_tab_name": env.get("NUDGE_ITERM_TAB_NAME"),
"claude_session_id": env.get("NUDGE_CLAUDE_SESSION_ID"),
"transcript_path": env.get("NUDGE_TRANSCRIPT_PATH"),
"claude_session_id": hook_field("session_id"),
"transcript_path": hook_field("transcript_path"),
"voice_message": env.get("NUDGE_VOICE_MESSAGE"),
"sound_name": env.get("NUDGE_SOUND"),
}
Expand Down
Loading