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
2 changes: 1 addition & 1 deletion cmd/driga/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ import (
)

func main() {
os.Exit(cli.Main(os.Args[1:], version, commit, date))
notificationMain(func() { os.Exit(cli.Main(os.Args[1:], version, commit, date)) })
}
35 changes: 35 additions & 0 deletions cmd/driga/main_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//go:build darwin

package main

import (
"os"
"runtime"
"strings"

"github.com/labstack/driga/internal/attention"
)

// Cocoa's application/run loop must own the initial OS thread. Lock in init,
// before Go can move main to a worker thread. CLI work runs on a Go goroutine.
func init() { runtime.LockOSThread() }

// Only the collector posts notifications. Other commands, including hooks
// that run on every agent event, skip AppKit and release the thread.
func notificationMain(run func()) {
if command(os.Args[1:]) != "collect" {
runtime.UnlockOSThread()
run()
return
}
attention.RunNotificationMain(run)
}

func command(args []string) string {
for _, a := range args {
if !strings.HasPrefix(a, "-") {
return a
}
}
return ""
}
5 changes: 5 additions & 0 deletions cmd/driga/main_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//go:build !darwin

package main

func notificationMain(run func()) { run() }
25 changes: 15 additions & 10 deletions docs/machine-health.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,25 +251,30 @@ does not claim a reclaim size it has not measured. No new cleanup kinds are
implemented.

`memory_pressure` starts after two consecutive, distinct critical OS samples.
Normal, warning, unknown, and failed collection break the streak. State survives
Normal, warning, unknown, and failed collection break the streak. Unknown or failed
readings do not clear an existing episode. State survives
collector restarts. Recovery ends the item while retaining its inbox history.
Acknowledging an item suppresses further notifications for that episode.
Pressure text leads with the agents' proven share of OS memory in use and MCP
count. With no proven agent memory it says agents hold none and other apps are
the cause. Likely memory is never silently included in the proven amount.
When MCP pile-ups exist, the pressure text includes a short count and the
largest group with its host and restart advice. Active unacknowledged episodes
refresh this evidence without changing their identity, timestamp, or throttle.
refresh this evidence without changing their identity or timestamp.
Growth evidence is labelled `Largest observed growth:`; no new growth alert is added.

Both kinds follow `inbox.alerts` (`off` disables them), `alert_repeat`, and the
existing three-second batching window. Repeat throttles are independent per
machine and kind. Unacknowledged active machine warnings can repeat after the
interval; delayed viewer/event passes cannot create extra samples. Health text
survives the titles-only policy, since it contains operational readings rather
than agent transcript text.

Notification clicks use the existing terminal-notifier `driga inbox jump <id>` path.
Both kinds follow `inbox.alerts` (`off` disables them). Each condition notifies
once per episode, including across collector restarts and metadata pruning;
only a confirmed recovery re-arms it. `alert_repeat` applies to session alerts.
Health items arriving in one committed batch coalesce per machine, even when
their original timestamps differ. Summaries have no single click destination.
Health text survives the titles-only policy, since it contains operational
readings rather than agent transcript text. Banner text is short; the inbox
keeps the detailed explanation and uses decimal GB/MB for sizes.

On macOS, Driga.app sends native UserNotifications with Driga's name and icon.
A local single-item notification click runs `driga inbox jump <id>` through its
delegate while the collector is running. Unbundled builds have no macOS alerts.
The stored destination is `machine`. On macOS, the click opens Terminal and runs
the installed executable's `top --tab machine`; on Linux, a manual jump uses
`x-terminal-emulator -e`. This avoids requiring a running web server. As with
Expand Down
38 changes: 28 additions & 10 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,30 @@ Remote inbox acknowledgments require the peer's `--allow-ack` grant; unreachable
show `ack pending` and retry on reconnect.

Desktop alerts show the session title or project and why it needs you, within
your capture policy. On macOS, `brew install terminal-notifier` enables clickable
alerts that run `driga inbox jump <item-id>` without acknowledging the item; otherwise
alerts use Script Editor via osascript. Jumps select a proven tmux pane, focus a
proven Terminal/iTerm window with `inbox.jump_terminal = true`, or print a resume
command. Batch summaries and remote alerts have no click action. Notifier
detection is cached until collector restart; `driga doctor` reports the backend.
your capture policy. macOS notifications require the installed **Driga.app**
(`brew install --cask labstack/tap/driga`); they use Driga's name and icon through the native
UserNotifications API. Allow alerts and sounds for Driga when macOS asks, or in
System Settings → Notifications → Driga. Unbundled builds (`go run`, checkout
binaries) cannot send macOS notifications; `driga doctor` explains how to install
the app. No notification helpers are needed. `driga doctor` warns when
notifications are turned off for Driga; turning them back on takes effect from
the next notification, without restarting the collector. Alerts raised while
notifications are off are not replayed; they stay in the inbox. Only the
background collector posts notifications on macOS, so run the service
(`driga service install`) to receive them.

Clicking a local item's notification runs `driga inbox jump <item-id>` without
acknowledging it. Jumps select a proven tmux pane, focus a proven Terminal/iTerm
window with `inbox.jump_terminal = true`, or print a resume command. Machine
warnings open Terminal on `driga top --tab machine`. Batch summaries and remote
alerts have no click action. The collector keeps the native notification delegate
and main run loop alive while it runs. Re-sending an item replaces its existing
notification. Linux uses notify-send; Windows uses native WinRT toasts.

Health warnings notify once per condition, until it clears. Concurrent health
warnings from one machine form one summary. Details and cleanup commands remain
in the inbox; low-disk banners show decimal sizes such as “12.2 GB free, below
20 GB”. `inbox.alert_repeat` throttles session alerts, not health episodes.

## Privacy and configuration

Expand Down Expand Up @@ -545,13 +563,13 @@ permissions and restart the collector to retry Chat.

## Cleanup

`driga cleanup` lists disk that agents left behind. Nothing is removed
`driga cleanup list` lists disk that agents left behind. Nothing is removed
automatically, and nothing is removed on another machine.

```sh
driga cleanup # candidates with evidence
driga cleanup --sizes # walk sizes on demand (30 s cap per walk)
driga cleanup --why # also list worktrees that don't qualify, and why
driga cleanup list # candidates with evidence
driga cleanup list --sizes # walk sizes on demand (30 s cap per walk)
driga cleanup list --why # also list worktrees that don't qualify, and why
driga cleanup remove <id> --dry-run # print the exact commands
driga cleanup remove <id> # re-verify, then ask y/N
driga cleanup remove --kind worktree --all # every ready worktree; skips anything changed
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (
github.com/bmatcuk/doublestar/v4 v4.10.3
github.com/charmbracelet/x/ansi v0.11.9
github.com/charmbracelet/x/term v0.2.2
github.com/ebitengine/purego v0.11.1
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3
github.com/google/go-cmp v0.7.0
github.com/google/jsonschema-go v0.4.3
Expand Down Expand Up @@ -40,7 +41,6 @@ require (
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/ebitengine/purego v0.11.1 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/lrstanley/bubblezone/v2 v2.0.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
Expand Down
78 changes: 1 addition & 77 deletions internal/attention/alerts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@ package attention

import (
"context"
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"time"
Expand All @@ -15,78 +11,6 @@ import (
"github.com/labstack/driga/internal/model"
)

func TestClickableNotifierArguments(t *testing.T) {
for _, found := range []bool{false, true} {
t.Run(map[bool]string{false: "fallback", true: "clickable"}[found], func(t *testing.T) {
var calls [][]string
r := runnerFunc(func(_ context.Context, name string, args ...string) ([]byte, error) {
calls = append(calls, append([]string{name}, args...))
return nil, nil
})
lookups := 0
lookup := cachedNotifierLookup(func(name string) (string, error) {
lookups++
if name != "terminal-notifier" {
t.Fatal(name)
}
if !found {
return "", exec.ErrNotFound
}
return "/opt/tool bin/terminal-notifier", nil
})
d := DesktopNotifier{Runner: r, OS: "darwin", DrigaPath: "/opt/Driga's bin/driga", Lookup: lookup}
n := Notification{Kind: "waiting", ItemID: "item '$(bad) with spaces", Title: "Needs you", Body: `driga · permission (Bash) "quoted"`, Sound: "Glass"}
if err := d.Notify(context.Background(), n); err != nil {
t.Fatal(err)
}
want := []string{"osascript", "-e", "on run argv\ndisplay notification (item 1 of argv) with title (item 2 of argv) sound name (item 3 of argv)\nend run", n.Body, n.Title, n.Sound}
if found {
want = []string{"/opt/tool bin/terminal-notifier", "-title", "driga", "-subtitle", n.Title, "-message", n.Body, "-group", "driga-" + n.ItemID, "-sound", "Glass", "-execute", `'/opt/Driga'"'"'s bin/driga' inbox jump 'item '"'"'$(bad) with spaces'`}
}
if !reflect.DeepEqual(calls, [][]string{want}) {
t.Fatalf("got %#v; want %#v", calls, want)
}
n.ItemID, n.Body = "", "3 sessions waiting"
if err := d.Notify(context.Background(), n); err != nil {
t.Fatal(err)
}
if lookups != 1 {
t.Fatalf("lookups = %d", lookups)
}
if found {
want = []string{"/opt/tool bin/terminal-notifier", "-title", "driga", "-subtitle", n.Title, "-message", n.Body, "-group", "driga-waiting", "-sound", "Glass"}
if !reflect.DeepEqual(calls[1], want) {
t.Fatal(calls[1])
}
}
})
}
}

func TestNotifierLookupUsesInheritedPATH(t *testing.T) {
dir := t.TempDir()
name := "terminal-notifier"
if runtime.GOOS == "windows" {
name += ".exe"
t.Setenv("PATHEXT", ".EXE")
}
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte("unused fixture"), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
lookup := cachedNotifierLookup(exec.LookPath)
if got := lookup(); got != path {
t.Fatalf("lookup = %q", got)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if got := lookup(); got != path {
t.Fatalf("lookup not cached: %q", got)
}
}

func TestLinuxNotifierExactArguments(t *testing.T) {
for _, kind := range []string{"waiting", "finished"} {
sound := "message-new-instant"
Expand All @@ -100,7 +24,7 @@ func TestLinuxNotifierExactArguments(t *testing.T) {
}
return nil, nil
})
d := DesktopNotifier{Runner: r, OS: "linux", Lookup: func() string { t.Fatal("Linux lookup"); return "" }}
d := DesktopNotifier{Runner: r, OS: "linux"}
if err := d.Notify(context.Background(), Notification{Kind: kind, Title: "title", Body: "body"}); err != nil {
t.Fatal(err)
}
Expand Down
30 changes: 30 additions & 0 deletions internal/attention/bytes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package attention

import (
"fmt"
"strings"
)

// DecimalBytes matches the decimal disk thresholds documented in the config.
func DecimalBytes(n uint64) string {
for _, unit := range []struct {
size uint64
name string
}{{1_000_000_000_000, "TB"}, {1_000_000_000, "GB"}, {1_000_000, "MB"}, {1_000, "kB"}} {
if n >= unit.size {
return strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/float64(unit.size)), ".0") + " " + unit.name
}
}
return fmt.Sprintf("%d B", n)
}

// DecimalBytesFloor truncates instead of rounding, for values shown against a
// limit: 19.96 GB free must not read as "20 GB free, below 20 GB".
func DecimalBytesFloor(n uint64) string {
for _, size := range []uint64{1_000_000_000_000, 1_000_000_000, 1_000_000, 1_000} {
if n >= size {
return DecimalBytes(n / (size / 10) * (size / 10))
}
}
return DecimalBytes(n)
}
Loading
Loading