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
21 changes: 15 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,21 @@ feat(tui): compact tool steps with Ctrl+E details toggle
text, MCP args, raw config JSON — everything through `sanitize()`),
`esc`/`q` folds back, `p` promotes in place. Tab switches reset it
(`switchDrawerTab`); keep that reset when adding new open paths.
- The jobs tab pairs with a REST lifecycle watcher (`jobs_tab.go`): odek
pushes no WS frames for background jobs and its completion notice never
leaves the LLM payload, so the TUI polls `/api/jobs` (10s in background,
3s while the tab is visible) and diffs status transitions into transient
notes. Generation counters (`jobsSeq`/`jobsWatchSeq`) drop stale ticks —
keep both chains generation-guarded when touching the cadence.
- The jobs tab pairs with a REST lifecycle watcher (`jobs_tab.go`): the
TUI polls `/api/jobs` (10s in background, 3s while the tab is visible)
and diffs status transitions into transient notes. odek ≥ v1.40 also
pushes `bg_job` frames on start/exit — `handleEvent` routes them through
`kickJobsFetch()` for an immediate snapshot, watcher tick as fallback;
`bg_wake` frames become transient notes. Generation counters
(`jobsSeq`/`jobsWatchSeq`) drop stale ticks — keep both chains
generation-guarded when touching the cadence.
- Server-initiated wake turns (odek ≥ v1.40, `system_initiated` on the
session frame) open a streaming card from the wire (`openWakeTurn` in
`events.go`): without it every streamed event drops, because cards only
opened on the local send path. The card carries the `systemWake` marker
(renders `⬡ odek · wake`); wake turns are never rendered as user
messages, and a wake frame arriving during an operator turn opens
nothing.

## Workflow rules for agents

Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,10 @@ own front-end settings are separate; see [Configuration](#configuration).
desktop notifications. Fires only on terminal states — never per token.
- **Sandbox aware** — the header shows `🛡 sandboxed` or `⚠ host access`;
pass `--sandbox` to run tool calls inside odek's Docker isolation.
- **Wake turns** — when a background job finishes while the session is
idle (odek ≥ v1.40), the engine wakes the model on its own; bodek opens
the turn from the wire, marks the card `⬡ odek · wake`, and streams the
model's report like any other turn — never rendered as a user message.

---

Expand Down Expand Up @@ -407,7 +411,9 @@ shared grammar:
every 3s; a background watcher surfaces starts as transcript notes and
exits as **alert-tier notes naming the command** — with a bell / desktop
notification (same gates as turns) so a finished job is never missed,
even with the tab closed. `⏎` opens the job's output viewer (`f`
even with the tab closed. odek ≥ v1.40 also pushes `bg_job` frames: the
snapshot refreshes the moment a job starts or exits, the watcher tick
stays as the fallback. `⏎` opens the job's output viewer (`f`
pages further output), `s` stops a running job (two-step, same gate as
`/stop`). Jobs are session-scoped: other sessions' jobs never appear.
- **Events** — the `odek.event/v1` ring: `f` filter to this session, `x`
Expand Down
4 changes: 4 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ type Event struct {
Model string `json:"model"`
Sandbox bool `json:"sandbox"`

// session — true when odek itself started the turn (wake-on-complete,
// ≥ v1.40); absent on operator turns.
SystemInitiated bool `json:"system_initiated,omitempty"`

// done — token economics for the turn and the session. ContextTokens is
// cumulative prompt tokens across all LLM calls of the run (the live
// window fill is the delta between consecutive reports); the Session*
Expand Down
47 changes: 47 additions & 0 deletions internal/client/wake_decode_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package client

import (
"encoding/json"
"testing"
)

// odek ≥ v1.40 stamps the session frame with system_initiated when the turn
// was started by the wake-on-complete dispatcher; the field is absent on
// operator turns. Event must decode both shapes.
func TestEventDecodeSystemInitiated(t *testing.T) {
var wake Event
if err := json.Unmarshal([]byte(`{"type":"session","session_id":"s1","system_initiated":true}`), &wake); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !wake.SystemInitiated {
t.Error("system_initiated=true did not decode onto Event.SystemInitiated")
}

var op Event
if err := json.Unmarshal([]byte(`{"type":"session","session_id":"s1"}`), &op); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if op.SystemInitiated {
t.Error("absent system_initiated must decode false (operator turns)")
}
}

// Wake-surface frames (bg_wake / bg_job) must decode far enough to dispatch
// on Type — the TUI routes them and refetches anything richer over REST.
func TestEventDecodeWakeFrames(t *testing.T) {
for _, tc := range []struct {
wire string
want string
}{
{`{"type":"bg_wake","session_id":"s1","t":1756830000000}`, "bg_wake"},
{`{"type":"bg_job","job_id":"bg_ab12","session_id":"s1","status":"exited"}`, "bg_job"},
} {
var ev Event
if err := json.Unmarshal([]byte(tc.wire), &ev); err != nil {
t.Fatalf("unmarshal %s: %v", tc.wire, err)
}
if ev.Type != tc.want {
t.Errorf("Type = %q, want %q", ev.Type, tc.want)
}
}
}
40 changes: 40 additions & 0 deletions internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
m.resolveMaxContext()
}
m.sandbox = ev.Sandbox
if ev.SystemInitiated && m.cur() < 0 && !m.busy {
m.openWakeTurn() // server-started turn: open the card from the wire
}

case "thinking", "thinking_delta":
// Bulk reasoning and live streamed fragments (streaming on) share one
Expand Down Expand Up @@ -346,6 +349,22 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
m.addTransientNote("stop declined · sub-agent already finished")
}

case "bg_wake":
// odek ≥ v1.40 enqueued a wake turn for a finished background job:
// the stamped session frame that follows opens the card; this note
// gives the operator the context for the unprompted activity.
m.addTransientNote("background job finished · agent waking")

case "bg_job":
// Push notification of a job start/exit (≥ v1.40): refresh the
// snapshot now instead of waiting for the next watcher tick. The
// REST watcher stays as the fallback; applyJobs diffs the
// transition into notes/attention as before.
if cmd := m.kickJobsFetch(); cmd != nil {
m.refresh()
return m, tea.Batch(listen(m.events), m.noticeSweep(), cmd)
}

case client.EventDisconnected:
m.disconn = true
m.busy = false
Expand Down Expand Up @@ -406,6 +425,27 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
return m, tea.Batch(listen(m.events), m.noticeSweep(), m.approvalSweep(), m.sendQueued(), m.planFollowup(), attn)
}

// openWakeTurn opens a streaming assistant card for a server-initiated turn
// (background-job wake): without it, every streaming event would find no
// open card and drop — cards historically opened only on the local send
// path. The card carries the systemWake marker so the unprompted turn is
// never mistaken for an operator exchange.
func (m *Model) openWakeTurn() {
m.msgs = append(m.msgs, message{role: roleAsst, streaming: true, systemWake: true})
m.curIdx = len(m.msgs) - 1
m.busy = true
m.cancelAck = false // a wake run's errors are real errors again
m.skillSuggest = nil // the suggestion's window closed with the last turn
m.status = "waking for bg job"
m.runStart = time.Now()
if m.sessionStart.IsZero() {
m.sessionStart = m.runStart
}
m.relayout() // the busy status line claims a row above the input
m.refresh()
m.vp.GotoBottom() // new activity: show it even when reading scrollback
}

// stepGlyphs returns up to 4 deduped tool glyphs for a turn's steps, in
// first-seen order, for the per-turn stat line.
func stepGlyphs(steps []step) []string {
Expand Down
11 changes: 11 additions & 0 deletions internal/tui/jobs_tab.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,17 @@ func (m *Model) fetchJobs() tea.Cmd {
}
}

// kickJobsFetch returns an immediate snapshot fetch for a bg_job push
// frame (odek ≥ v1.40). The REST watcher stays as the fallback; nil when
// the surface is unavailable or there is no live session to fetch with
// (fetchJobs no-ops on both).
func (m *Model) kickJobsFetch() tea.Cmd {
if m.jobsOff {
return nil
}
return m.fetchJobs()
}

// applyJobs stores a snapshot; watcher diffs become alert-tier notes — a
// finished job is actionable, not housekeeping — and terminal transitions
// fire the attention layer (bell / OSC 9). The first successful snapshot
Expand Down
23 changes: 12 additions & 11 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,18 @@ type turnStats struct {

// message is one entry in the transcript.
type message struct {
role role
content string // raw text/markdown
rendered string // cached glamour render (assistant, finalized)
thinking string // captured reasoning for this turn (finalized)
steps []step
items []turnItem // chronological timeline of reasoning blocks and tool calls
streaming bool
stats *turnStats // finalized-turn telemetry; nil while streaming / for history
raw bool // content is pre-styled; render verbatim, never re-render
sentAt time.Time // user turns: when the prompt was submitted (drives the head's age)
collapsed bool // turn card folded to its head + summary line (c)
role role
content string // raw text/markdown
rendered string // cached glamour render (assistant, finalized)
thinking string // captured reasoning for this turn (finalized)
steps []step
items []turnItem // chronological timeline of reasoning blocks and tool calls
streaming bool
stats *turnStats // finalized-turn telemetry; nil while streaming / for history
raw bool // content is pre-styled; render verbatim, never re-render
sentAt time.Time // user turns: when the prompt was submitted (drives the head's age)
collapsed bool // turn card folded to its head + summary line (c)
systemWake bool // server-initiated turn (background-job wake): marker on the card
}

// Options carries startup display info into the model.
Expand Down
5 changes: 5 additions & 0 deletions internal/tui/plain.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ func (m *Model) plainEventLines(ev client.Event) []string {
}
return []string{plainClip("· subagent · " + line + eventTail(ev))}

case "session":
if ev.SystemInitiated {
return []string{"[wake] background job finished — agent turning"}
}

case "done":
var lines []string
if reply := m.lastReply(); reply != "" {
Expand Down
6 changes: 6 additions & 0 deletions internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,12 @@ func (m *Model) renderMessage(msg message, msgIdx, lineOffset int) (string, []st
// segments shed in priority order under width pressure, exactly like
// the old foot line.
label := th.asstLabel.Render("⬡ odek")
if msg.systemWake {
// Server-initiated wake (background-job completion): the marker
// is the card's identity, so it sits left of the telemetry and
// sheds last.
label += th.asstLabel.Render(" · wake")
}
if msg.stats != nil {
limit := m.vp.Width - lipgloss.Width(label) - 4
if s := m.joinStatSegs(m.statSegments(*msg.stats), limit); s != "" {
Expand Down
Loading