Skip to content
Closed
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ The same UI is also served in your browser at `http://localhost:8484` whenever `
- **Git Worktrees** - Each task runs in an isolated worktree, no conflicts between parallel tasks
- **Pluggable Executors** - Choose between Claude Code, OpenAI Codex, Gemini, Pi, OpenClaw, or OpenCode per task
- **Event Hooks** - Run scripts when tasks change state (see [Event Hooks](#event-hooks))
- **Push Notifications** - Get a push (ntfy or Telegram) when a task needs you, with a one-tap reply to unblock it from your phone (see [docs/notifications.md](docs/notifications.md))
- **Ghost Text Autocomplete** - LLM-powered suggestions for task titles and descriptions as you type
- **VS Code-style Fuzzy Search** - Quick task navigation with smart matching (e.g., "dsno" matches "diseno website")
- **Markdown Rendering** - Task descriptions render with proper formatting in the detail view
Expand Down
8 changes: 8 additions & 0 deletions cmd/task/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ func completeSettingKeys(cmd *cobra.Command, args []string, toComplete string) (
"idle_suspend_timeout\tIdle timeout before suspending (e.g. 6h)",
"http_api_port\tPort for the daemon-hosted HTTP API (default 8080)",
"http_api_disabled\tDisable the daemon-hosted HTTP API (true/false)",
"notify_enabled\tEnable push notifications (true/false)",
"notify_base_url\tExternally reachable HTTP API base for one-tap actions",
"notify_unblock_reply\tCanned reply sent on a one-tap unblock",
"notify_ntfy_server\tntfy server base URL (default https://ntfy.sh)",
"notify_ntfy_topic\tntfy topic to publish to",
"notify_ntfy_token\tntfy access token for protected topics (secret)",
"notify_telegram_token\tTelegram bot token (secret)",
"notify_telegram_chat_id\tTelegram chat ID to deliver to",
}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
Expand Down
62 changes: 60 additions & 2 deletions cmd/task/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"github.com/bborn/workflow/internal/github"
"github.com/bborn/workflow/internal/hooks"
"github.com/bborn/workflow/internal/mcp"
"github.com/bborn/workflow/internal/notify"
"github.com/bborn/workflow/internal/ui"
"github.com/bborn/workflow/internal/web"
)
Expand Down Expand Up @@ -84,6 +85,15 @@ func openTaskDB(path string) (*db.DB, error) {
if taskEmitter == nil {
taskEmitter = events.New(hooks.DefaultHooksDir())
}
// Bind the push notifier to the database this caller is using so settings
// (and the latest needs-input question) are read from the live handle.
ntf := notify.New(database)
if os.Getenv("TY_NOTIFY_DEBUG") != "" {
ntf.SetLogf(func(format string, args ...any) {
fmt.Fprintf(os.Stderr, "[notify] "+format+"\n", args...)
})
}
taskEmitter.SetNotifier(ntf)
database.SetEventEmitter(taskEmitter)
return database, nil
}
Expand Down Expand Up @@ -2339,6 +2349,25 @@ servers programmatically.`,
}
fmt.Printf("idle_suspend_timeout: %s\n", idleTimeout)

// Push notifications
notifyEnabled, _ := database.GetSetting(config.SettingNotifyEnabled)
if notifyEnabled == "" {
notifyEnabled = "false (default)"
}
fmt.Printf("notify_enabled: %s\n", notifyEnabled)
if ntfyTopic, _ := database.GetSetting(config.SettingNtfyTopic); ntfyTopic != "" {
fmt.Printf("notify_ntfy_topic: %s\n", ntfyTopic)
}
if ntfyToken, _ := database.GetSetting(config.SettingNtfyToken); ntfyToken != "" {
fmt.Printf("notify_ntfy_token: %s\n", dimStyle.Render("(set — hidden)"))
}
if tgToken, _ := database.GetSetting(config.SettingTelegramToken); tgToken != "" {
fmt.Printf("notify_telegram_token: %s\n", dimStyle.Render("(set — hidden)"))
}
if baseURL, _ := database.GetSetting(config.SettingNotifyBaseURL); baseURL != "" {
fmt.Printf("notify_base_url: %s\n", baseURL)
}

fmt.Println()
fmt.Println(dimStyle.Render("Use 'task settings set <key> <value>' to change settings"))
},
Expand All @@ -2356,7 +2385,18 @@ Available settings:
autocomplete_enabled Enable/disable ghost text autocomplete (true/false)
idle_suspend_timeout How long blocked tasks wait before suspending (e.g. 6h, 30m, 24h)
http_api_port Port the daemon-hosted HTTP API listens on (default 8080)
http_api_disabled Stop the daemon from hosting the HTTP API (true/false)`,
http_api_disabled Stop the daemon from hosting the HTTP API (true/false)

Push notifications (off by default; see docs/notifications.md):
notify_enabled Turn push notifications on/off (true/false)
notify_base_url Externally reachable HTTP API base for one-tap actions
(e.g. https://ty.my-tailnet.ts.net:8080)
notify_unblock_reply Canned reply sent on a one-tap unblock (default "continue")
notify_ntfy_server ntfy server base URL (default https://ntfy.sh)
notify_ntfy_topic ntfy topic to publish to (enables the ntfy provider)
notify_ntfy_token ntfy access token for protected topics (secret)
notify_telegram_token Telegram bot token (secret; enables Telegram)
notify_telegram_chat_id Telegram chat ID to deliver to`,
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
key := args[0]
Expand Down Expand Up @@ -2389,9 +2429,27 @@ Available settings:
fmt.Println(errorStyle.Render("Value must be 'true' or 'false'"))
return
}
case config.SettingNotifyEnabled:
if value != "true" && value != "false" {
fmt.Println(errorStyle.Render("Value must be 'true' or 'false'"))
return
}
case config.SettingNotifyBaseURL, config.SettingNtfyServer:
if !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") {
fmt.Println(errorStyle.Render("Value must be an http:// or https:// URL"))
return
}
case config.SettingNotifyUnblockReply,
config.SettingNtfyTopic, config.SettingNtfyToken,
config.SettingTelegramToken, config.SettingTelegramChatID:
// Free-form strings; no validation beyond being non-empty.
if value == "" {
fmt.Println(errorStyle.Render("Value must not be empty"))
return
}
default:
fmt.Println(errorStyle.Render("Unknown setting: " + key))
fmt.Println(dimStyle.Render("Available: anthropic_api_key, autocomplete_enabled, idle_suspend_timeout, http_api_port, http_api_disabled"))
fmt.Println(dimStyle.Render("Available: anthropic_api_key, autocomplete_enabled, idle_suspend_timeout, http_api_port, http_api_disabled, notify_enabled, notify_base_url, notify_unblock_reply, notify_ntfy_server, notify_ntfy_topic, notify_ntfy_token, notify_telegram_token, notify_telegram_chat_id"))
return
}

Expand Down
119 changes: 119 additions & 0 deletions docs/notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Push notifications & one-tap unblock

TaskYou can push a notification to your phone when a task needs you — and let
you act on it with one tap, without opening a laptop. Notifications fire on task
lifecycle events and are delivered through [ntfy](https://ntfy.sh) (simplest)
or a Telegram bot.

**Notifications are OFF by default.** Nothing is sent until you turn them on and
configure a provider.

## What fires a push

| Event | When | Push |
|-------|------|------|
| `task.blocked` | A task calls `taskyou_needs_input`, or is otherwise blocked waiting on you | 🔔 "Needs input" + one-tap reply action |
| `task.auth_required` | An executor session needs re-authentication (e.g. expired login) | 🔐 "Auth required" + one-tap reply action |
| `task.completed` | A task finishes (or its PR is up for review) | ✅ "Completed" |
| `task.failed` | A task fails | ❌ "Failed" |

The body always includes the **task title**, **project**, and a **short
reason** — for needs-input pushes that reason is the actual question the agent
asked.

These hook into the existing event system (`internal/events`), so every code
path that changes task state — the executor, MCP (`taskyou_needs_input`,
`taskyou_complete`), CLI, and the TUI — produces a push. No parallel path.

## Quick start (ntfy)

1. Install the [ntfy app](https://ntfy.sh/app) on your phone and subscribe to a
private, hard-to-guess topic (e.g. `ty-bruno-7f3a9c`).

2. Configure TaskYou:

```sh
ty settings set notify_enabled true
ty settings set notify_ntfy_topic ty-bruno-7f3a9c
```

3. To make the **one-tap reply** button work from your phone, the daemon's HTTP
API must be reachable from the internet (or your VPN/tailnet). Point
TaskYou at that reachable base URL:

```sh
ty settings set notify_base_url https://ty.my-tailnet.ts.net:8080
```

If you don't set `notify_base_url`, action links fall back to
`http://localhost:<http_api_port>`, which only works on the same machine.

That's it. Block a task (or have an agent call `taskyou_needs_input`) and you'll
get a push within a few seconds.

## One-tap unblock — how it works

A needs-input / auth-required push carries two action buttons:

- **Reply "continue"** — an ntfy `http` action that POSTs to the existing web
API, `POST /api/tasks/{id}/input`, with `{"message":"continue"}`. That types
the reply into the agent's session and presses Enter, resuming the task. The
canned reply is configurable:

```sh
ty settings set notify_unblock_reply "yes, go ahead"
```

- **Open task** — a `view` action that opens the web UI so you can type a
full custom reply.

The round trip is: push → tap → `POST /api/tasks/{id}/input` → `tmux
send-keys` into the executor pane → agent resumes.

## Protected ntfy topics

If your topic requires auth, set an access token (stored as a secret and hidden
from `ty settings` / the settings API):

```sh
ty settings set notify_ntfy_token tk_xxxxxxxxxxxxxxxx
```

You can also self-host ntfy and point at it:

```sh
ty settings set notify_ntfy_server https://ntfy.example.com
```

## Telegram (optional second provider)

[Create a bot](https://core.telegram.org/bots#how-do-i-create-a-bot) via
@BotFather, then find your chat ID (e.g. message
[@userinfobot](https://t.me/userinfobot)):

```sh
ty settings set notify_telegram_token 123456:ABC-DEF...
ty settings set notify_telegram_chat_id 987654321
```

Telegram inline buttons can only navigate (not POST), so Telegram pushes get an
**Open task** deep link to the web UI rather than a true one-tap reply. Use ntfy
for one-tap unblock.

If both ntfy and Telegram are configured, pushes go to both.

## All settings

| Key | Default | Notes |
|-----|---------|-------|
| `notify_enabled` | `false` | Master on/off switch |
| `notify_base_url` | `http://localhost:<port>` | Externally reachable HTTP API base for action links |
| `notify_unblock_reply` | `continue` | Canned reply for the one-tap action |
| `notify_ntfy_server` | `https://ntfy.sh` | ntfy server base URL |
| `notify_ntfy_topic` | — | ntfy topic; setting it enables the ntfy provider |
| `notify_ntfy_token` | — | ntfy access token for protected topics (secret) |
| `notify_telegram_token` | — | Telegram bot token (secret); setting it + chat ID enables Telegram |
| `notify_telegram_chat_id` | — | Telegram chat ID |

Settings whose names contain `token`/`key`/`secret`/`password` are never shown
by `ty settings` or returned by the settings API.
37 changes: 37 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,45 @@ const (
// SettingHTTPAPIDisabled, when "true", stops the daemon from hosting the
// HTTP API (for headless/security-sensitive boxes). The API is on by default.
SettingHTTPAPIDisabled = "http_api_disabled"

// Push notification settings. OFF by default — nothing is sent unless
// SettingNotifyEnabled is "true" AND a provider is configured. See
// internal/notify for the delivery logic.

// SettingNotifyEnabled, when "true", turns on push notifications for task
// lifecycle events (blocked/needs-input, auth-required, completed, failed).
SettingNotifyEnabled = "notify_enabled"
// SettingNotifyBaseURL is the externally reachable base URL of the daemon
// HTTP API, used to build one-tap action links in notifications (e.g.
// "https://ty.my-tailnet.ts.net:8080"). Falls back to http://localhost:<port>.
SettingNotifyBaseURL = "notify_base_url"
// SettingNotifyUnblockReply is the canned reply sent to a blocked task when
// the user taps the one-tap action button. Defaults to "continue".
SettingNotifyUnblockReply = "notify_unblock_reply"

// ntfy (https://ntfy.sh) provider.
// SettingNtfyServer is the ntfy server base URL (default https://ntfy.sh).
SettingNtfyServer = "notify_ntfy_server"
// SettingNtfyTopic is the ntfy topic to publish to. A bare topic name or a
// full topic URL. Empty disables the ntfy provider.
SettingNtfyTopic = "notify_ntfy_topic"
// SettingNtfyToken is an optional ntfy access token for protected topics.
// Treated as a secret (hidden from `ty settings` and the settings API).
SettingNtfyToken = "notify_ntfy_token" //nolint:gosec // G101: this is a settings-key name, not a credential

// Telegram bot provider.
// SettingTelegramToken is the Telegram bot token. Secret. Empty disables it.
SettingTelegramToken = "notify_telegram_token" //nolint:gosec // G101: this is a settings-key name, not a credential
// SettingTelegramChatID is the Telegram chat ID to deliver messages to.
SettingTelegramChatID = "notify_telegram_chat_id"
)

// DefaultNtfyServer is the ntfy server used when SettingNtfyServer is unset.
const DefaultNtfyServer = "https://ntfy.sh"

// DefaultUnblockReply is the canned reply sent on a one-tap unblock action.
const DefaultUnblockReply = "continue"

// DefaultHTTPAPIPort is the port the daemon-hosted HTTP API binds by default.
// Matches the standalone `ty serve` default so existing clients (ty-web, the
// ty-chrome extension) keep working without reconfiguration.
Expand Down
52 changes: 44 additions & 8 deletions internal/events/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,24 @@ type Event struct {
Timestamp time.Time `json:"timestamp"`
}

// Notifier receives every emitted event so it can deliver push notifications.
// It is implemented by internal/notify. Kept as an interface here so the events
// package stays free of provider/config dependencies.
//
// Notify is invoked synchronously while the caller's database handle is still
// open, so it must read any state it needs (settings, task logs) before
// returning. It returns a delivery closure to run asynchronously — or nil if
// nothing should be sent. Splitting it this way keeps slow network I/O off the
// caller's path while ensuring DB reads never race a deferred db.Close() in
// short-lived CLI/MCP commands.
type Notifier interface {
Notify(eventType string, task *db.Task, message string) func()
}

// Emitter handles event emission via hooks.
type Emitter struct {
hooksDir string
notifier Notifier
wg sync.WaitGroup
}

Expand All @@ -53,21 +68,42 @@ func New(hooksDir string) *Emitter {
return &Emitter{hooksDir: hooksDir}
}

// SetNotifier attaches a push notifier. Once set, every emitted event is also
// forwarded to it (the notifier itself decides what, if anything, to send).
func (e *Emitter) SetNotifier(n Notifier) {
e.notifier = n
}

// Emit triggers a hook script if it exists for the event type.
// Hooks run in a background goroutine — short-lived CLI commands should
// call Wait before exiting so the hook actually runs.
func (e *Emitter) Emit(event Event) {
if e.hooksDir == "" {
return
}
if event.Timestamp.IsZero() {
event.Timestamp = time.Now()
}
e.wg.Add(1)
go func() {
defer e.wg.Done()
e.runHook(event)
}()

// Run the matching hook script (if a hooks dir is configured).
if e.hooksDir != "" {
e.wg.Add(1)
go func() {
defer e.wg.Done()
e.runHook(event)
}()
}

// Fan the event out to the push notifier. The notifier reads state
// synchronously here (DB still open) and hands back a delivery closure that
// we run on the same wait group, so short-lived CLI/MCP commands flush
// notifications via Wait before exit. Works even with no hooks dir.
if e.notifier != nil {
if deliver := e.notifier.Notify(event.Type, event.Task, event.Message); deliver != nil {
e.wg.Add(1)
go func() {
defer e.wg.Done()
deliver()
}()
}
}
}

// Wait blocks until all in-flight hooks have completed.
Expand Down
Loading
Loading