Skip to content
Merged
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ Auto-approve mode skips interactive permission dialogs for `ask`-resolved permis
- **Scope**: per-session only — new sessions start without auto-approve
- **Subagents**: child task sessions inherit auto-approve from the parent
- **Non-interactive mode**: already auto-approves all permissions, flag is ignored
- **Questions still ask**: auto-approve covers tool permissions, not decisions — the `question` tool keeps prompting you (TUI dialog, chat bridge, API). It answers itself with the first (recommended) option only where nobody could answer: a headless `opencode -p` run, a flow step, or a cron job firing on an unwatched session

### Shell

Expand Down
79 changes: 79 additions & 0 deletions cmd/drain_notifier.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package cmd

import (
"sync"

tea "charm.land/bubbletea/v2"
appPkg "github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/logging"
)

// drainEventBuffer is the depth of the forwarder queue. Drain events are
// low-rate (one per enqueue / delivery / discard), so this is generous enough
// that the out-of-band spill path below is effectively unreachable.
const drainEventBuffer = 128

// newDrainForwarder adapts App.SetDrainNotifier to tea.Program.Send.
//
// It exists because Program.Send MUST NOT be called from the Bubble Tea update
// goroutine: p.msgs is unbuffered and is only read by the event loop, which is
// itself blocked inside Model.Update while the update runs. A bare
// `program.Send(e)` notifier therefore deadlocks the entire TUI permanently the
// first time a user submits a message while the agent is busy — the editor's
// enqueue path (chat/editor.go send) and the ctrl+x discard path both call into
// App.EnqueueMessage / App.DiscardQueue synchronously from Update, and those
// notify the registered callback before returning. Bubble Tea itself always
// wraps internal self-sends in `go p.Send(...)` for the same reason.
//
// The returned notify never blocks its caller and preserves event order: events
// are handed to a single forwarder goroutine over a buffered channel, mirroring
// how service subscriptions reach the TUI (see setupSubscriptions). stop halts
// the forwarder and waits for it to exit.
func newDrainForwarder(send func(tea.Msg)) (notify func(appPkg.DrainEvent), stop func()) {
events := make(chan appPkg.DrainEvent, drainEventBuffer)
stopped := make(chan struct{})
exited := make(chan struct{})
var stopOnce sync.Once

go func() {
defer close(exited)
defer logging.RecoverPanic("TUI-drain-forwarder", nil)

for {
select {
case <-stopped:
return
case e := <-events:
send(e)
}
}
}()

notify = func(e appPkg.DrainEvent) {
select {
case events <- e:
case <-stopped:
default:
// Buffer full: hand off to a goroutine rather than blocking the
// caller, which may be the Bubble Tea update goroutine. Delivery
// is preserved (error events are the only signal a halted drain
// worker ever emits) at the cost of ordering under backpressure —
// harmless, since the queue banner re-reads QueueLen in View.
logging.Warn("drain event forwarder buffer full, delivering out of band",
"session", e.SessionID)
go func() {
select {
case events <- e:
case <-stopped:
}
}()
}
}

stop = func() {
stopOnce.Do(func() { close(stopped) })
<-exited
}

return notify, stop
}
157 changes: 157 additions & 0 deletions cmd/drain_notifier_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package cmd

import (
"io"
"strings"
"sync"
"testing"
"time"

tea "charm.land/bubbletea/v2"
appPkg "github.com/opencode-ai/opencode/internal/app"
)

// Regression test for the queue-message freeze: the notifier registered on the
// App is invoked synchronously from the Bubble Tea update goroutine (editor
// enqueue, ctrl+x discard). If it forwards straight to tea.Program.Send it
// blocks forever, because Send writes to an unbuffered channel that only the
// event loop reads — and the event loop is inside Update, waiting for the
// notifier to return. The forwarder must never block its caller.
func TestDrainForwarder_NotifyNeverBlocksCaller(t *testing.T) {
release := make(chan struct{})
var sent []appPkg.DrainEvent
var mu sync.Mutex

blockingSend := func(msg tea.Msg) {
<-release // stands in for the wedged event loop
mu.Lock()
defer mu.Unlock()
sent = append(sent, msg.(appPkg.DrainEvent))
}

notify, stop := newDrainForwarder(blockingSend)
defer func() {
close(release)
stop()
}()

done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < drainEventBuffer+8; i++ {
notify(appPkg.DrainEvent{SessionID: "s1", QueueLen: i})
}
}()

select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("notify blocked while the consumer was stalled — the TUI would freeze")
}
}

// Once the event loop is free again, buffered events are delivered in order.
func TestDrainForwarder_DeliversInOrder(t *testing.T) {
const n = 16

got := make(chan int, n)
notify, stop := newDrainForwarder(func(msg tea.Msg) {
got <- msg.(appPkg.DrainEvent).QueueLen
})
defer stop()

for i := 0; i < n; i++ {
notify(appPkg.DrainEvent{SessionID: "s1", QueueLen: i})
}

for i := 0; i < n; i++ {
select {
case v := <-got:
if v != i {
t.Fatalf("event %d delivered out of order: got QueueLen=%d", i, v)
}
case <-time.After(3 * time.Second):
t.Fatalf("event %d never delivered", i)
}
}
}

// stop is idempotent and safe to call while notify is still being used, and
// post-stop notifies are dropped rather than panicking on a closed channel.
func TestDrainForwarder_StopIsSafe(t *testing.T) {
notify, stop := newDrainForwarder(func(tea.Msg) {})

notify(appPkg.DrainEvent{SessionID: "s1"})
stop()
stop()

done := make(chan struct{})
go func() {
defer close(done)
notify(appPkg.DrainEvent{SessionID: "s1"})
}()

select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("notify blocked after stop")
}
}

// End-to-end version of the freeze against a real tea.Program: notifying from
// inside Model.Update (what EnqueueMessage / DiscardQueue do) must return. With
// a raw `program.Send` notifier this test hangs until the timeout, which is the
// exact user-visible bug — an unrecoverable TUI freeze.
func TestDrainForwarder_NotifyFromUpdateDoesNotDeadlock(t *testing.T) {
m := &forwarderProbeModel{
entered: make(chan struct{}, 1),
returned: make(chan struct{}, 1),
}
program := tea.NewProgram(m,
tea.WithInput(strings.NewReader("")),
tea.WithOutput(io.Discard),
)
notify, stop := newDrainForwarder(program.Send)
defer stop()
m.notify = notify

go func() { _, _ = program.Run() }()
// Quit is itself a Send, so it deadlocks too when the bug is present —
// fire it off-goroutine so a regression fails on the timeouts below
// instead of hanging the whole test binary.
defer func() { go program.Quit() }()

select {
case <-m.entered:
case <-time.After(5 * time.Second):
t.Fatal("Update never ran")
}
select {
case <-m.returned:
case <-time.After(3 * time.Second):
t.Fatal("notify from inside Update never returned — the TUI is deadlocked")
}
}

type forwarderProbeMsg struct{}

type forwarderProbeModel struct {
notify func(appPkg.DrainEvent)
entered chan struct{}
returned chan struct{}
}

func (m *forwarderProbeModel) Init() tea.Cmd {
return func() tea.Msg { return forwarderProbeMsg{} }
}

func (m *forwarderProbeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := msg.(forwarderProbeMsg); ok {
m.entered <- struct{}{}
m.notify(appPkg.DrainEvent{SessionID: "s1", QueueLen: 1})
m.returned <- struct{}{}
}
return m, nil
}

func (m *forwarderProbeModel) View() tea.View { return tea.NewView("") }
4 changes: 4 additions & 0 deletions cmd/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ func runNonInteractive(ctx context.Context, a *app.App, prompt string, outputFor
}

a.Permissions.AutoApproveSession(sess.ID)
// Nobody is watching a headless `-p` run: there is no TUI dialog and no
// chat binding, so the question tool must answer itself instead of
// blocking forever on a prompt no human will ever see.
a.Permissions.MarkUnattendedSession(sess.ID)

// Headless prompt invocation is non-interactive: hold the turn open
// until background tasks (bash run_in_background, task async, monitor)
Expand Down
18 changes: 15 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
"time"

tea "charm.land/bubbletea/v2"
"github.com/opencode-ai/opencode/internal/app"
appPkg "github.com/opencode-ai/opencode/internal/app"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/db"
"github.com/opencode-ai/opencode/internal/flow"
Expand Down Expand Up @@ -174,7 +174,7 @@ to assist developers in writing, debugging, and understanding code directly from
// Create main context for the application
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app, err := app.New(ctx, conn, cliSchema, projectID)
app, err := appPkg.New(ctx, conn, cliSchema, projectID)
if err != nil {
if spinner != nil {
spinner.Stop()
Expand Down Expand Up @@ -269,6 +269,14 @@ to assist developers in writing, debugging, and understanding code directly from
tui.New(app),
)

// Wire the drain notifier: drain workers and the editor's enqueue path
// push DrainEvents (queue count updates and attributed errors) into the
// TUI event loop. The forwarder goroutine is mandatory — notifying from
// the Bubble Tea update goroutine with a bare program.Send deadlocks the
// TUI (see newDrainForwarder).
drainNotify, stopDrainForwarder := newDrainForwarder(program.Send)
app.SetDrainNotifier(drainNotify)

// Setup the subscriptions, this will send services events to the TUI
ch, permCh, cancelSubs := setupSubscriptions(app, ctx)

Expand Down Expand Up @@ -340,6 +348,10 @@ to assist developers in writing, debugging, and understanding code directly from
}()
cleanupWg.Wait()

// Drain workers are stopped by app.Shutdown above, so no further
// events can be emitted; release the forwarder goroutine.
stopDrainForwarder()

logging.Info("All goroutines cleaned up")
}

Expand Down Expand Up @@ -446,7 +458,7 @@ func setupBlockingSubscriber[T any](
}()
}

func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, chan tea.Msg, func()) {
func setupSubscriptions(app *appPkg.App, parentCtx context.Context) (chan tea.Msg, chan tea.Msg, func()) {
ch := make(chan tea.Msg, 100)
permCh := make(chan tea.Msg, 10)

Expand Down
54 changes: 54 additions & 0 deletions cmd/schema/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -998,5 +998,59 @@ func generateSchema() map[string]any {
},
}

// Add router (chat-bridge) configuration. When updating this schema, also
// update internal/bridge/config.go (bridge.Config) and docs/bridge.md.
schema["properties"].(map[string]any)["router"] = map[string]any{
"type": "object",
"description": "Chat-bridge configuration. The bridge connects opencode to Telegram, Slack, and Mattermost. Set at least one channel identity to enable.",
"properties": map[string]any{
"questionMode": map[string]any{
"type": "string",
"description": "How agent questions are surfaced: 'interactive' renders platform-native UI (buttons/blocks); 'auto-reject' returns the default without prompting; 'disabled' suppresses the question flow.",
"enum": []string{"interactive", "auto-reject", "disabled"},
},
"permissionMode": map[string]any{
"type": "string",
"description": "How permission requests are resolved on bridge-owned sessions: 'allow' auto-approves, 'deny' auto-denies, 'ask' or empty defers to the opencode UI (hangs headless). Unrecognised values fail-safe to deny.",
"enum": []string{"allow", "deny", "ask"},
},
"toolUpdatesEnabled": map[string]any{
"type": "boolean",
"description": "Stream tool-call lifecycle events (pending/running/completed) to the chat surface. Failures always surface regardless of this flag.",
"default": false,
},
"toolUpdateVerbosity": map[string]any{
"type": "string",
"description": "Detail level when toolUpdatesEnabled is true: 'compact' (default) emits one line per call with glyph, name, and elapsed time; 'full' adds argument and result detail.",
"enum": []string{"compact", "full"},
"default": "compact",
},
"questionNudgeIntervalSeconds": map[string]any{
"type": "integer",
"description": "Idle gap (seconds) after which the bridge re-posts a 'still waiting' nudge to a session with an outstanding question. 0 = built-in default (300 s); <0 = disable nudging.",
},
"questionNudgeMax": map[string]any{
"type": "integer",
"description": "Maximum nudges per pending question. 0 = built-in default (3); <0 = unlimited.",
},
"queueAcknowledgementsEnabled": map[string]any{
"type": "boolean",
"description": "When true, the bridge sends an in-place-editable '⏳ queued' acknowledgement to a sender whose message is enqueued behind an in-flight agent run. The ack is updated as the queue drains and resolved to '▶ Processing…' when the run starts. Disabled by default; enable for reviewers who need visibility into queue depth.",
"default": false,
},
"channels": map[string]any{
"type": "object",
"description": "Per-platform channel configuration. See docs/bridge.md for field details.",
"properties": map[string]any{
"telegram": map[string]any{"type": "object", "description": "Telegram channel configuration."},
"slack": map[string]any{"type": "object", "description": "Slack channel configuration."},
"mattermost": map[string]any{"type": "object", "description": "Mattermost channel configuration."},
"external": map[string]any{"type": "object", "description": "External relay channel configuration."},
},
},
},
"additionalProperties": false,
}

return schema
}
1 change: 1 addition & 0 deletions docs/bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Health snapshot: `curl http://127.0.0.1:3456/router/health` (per-adapter `status
| `permissionMode` | `"allow"` \| `"deny"` \| `"ask"` \| empty | How the bridge resolves agent permission requests on bridge-bound sessions. `allow`/`deny` auto-resolve; `ask`/empty defer to opencode's default UI (will hang headless). Unrecognised values fail-safe to deny with a one-shot WARN log. |
| `toolUpdatesEnabled` | `bool` | Stream tool-call lifecycle to chat. Detail level is set by `toolUpdateVerbosity`. Failures surface regardless of this flag. |
| `toolUpdateVerbosity` | `"compact"` (default) \| `"full"` | `compact` emits **one line per tool call**: `🔧 <tool>#<id>` while running, updated in place to `✓ <tool>#<id> · <duration>` on completion — arguments and result bodies stay out of chat (they're in the session store and Langfuse). Failures always append a truncated reason: `✗ <tool>#<id> · <duration> · <reason>`. `full` restores the argument summary on the call line and a truncated result body on completion. Unrecognised values fall back to `compact` with a one-shot WARN. Flip it live with `/verbosity`. |
| `queueAcknowledgementsEnabled` | `bool` | When `true`, sends an in-place-editable `⏳ queued` acknowledgement to a sender whose message is enqueued behind an in-flight agent run. The ack is edited as the queue drains and resolved to `▶ Processing your message now…` the moment the run starts. Requires 2 seconds of queuing before sending, to avoid a pointless flash for sub-second waits. Default: `false`. All three production adapters (Telegram, Slack, Mattermost) support in-place edit; the external adapter silently skips acks. |
| `channels.{telegram,slack,mattermost,external}` | object | Per-platform configuration; see below. |

## Per-channel configuration
Expand Down
2 changes: 2 additions & 0 deletions docs/crons.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ A cron job lives in a session. If that session isn't the active one in the TUI a

Auto-approved jobs and jobs with explicit `cron: allow` rules run regardless of which session is active.

A job that fires on a session nothing is watching (not the TUI's selected session, not bridge-bound) runs **unattended**: if its agent calls the `question` tool, the tool answers itself with the first (recommended) option instead of blocking on a prompt nobody would see. The verdict is re-evaluated on every fire, so focusing the session in the TUI — or binding chat to it — brings the real prompt back.

### Session became busy after task ran

The scheduler tries to commit the synthetic `task_call`/`task_result` pair into the parent session atomically — it briefly holds the session-busy slot to prevent the parent agent from inserting a message in between. If a user message arrives during the narrow window between "we ran the task" and "we got the lock", the synthetic write is skipped and the result is preserved only on the cron row (visible via the crons page). The job still advances `next_run_at` correctly; it does not re-fire.
Expand Down
Loading
Loading