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
111 changes: 65 additions & 46 deletions README.md

Large diffs are not rendered by default.

139 changes: 139 additions & 0 deletions docs/slash-command-support.md

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions internal/codex/daemon_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ type sessionStatusProvider interface {
}

type sessionDaemonSnapshot struct {
Contexts map[string]SessionContext
Statuses map[string]sessionRuntimeStatus
ModelObservations []resolvedModelObservation
SubscribedThreads map[string]struct{}
Contexts map[string]SessionContext
WorkingDirectories map[string]string
Statuses map[string]sessionRuntimeStatus
ModelObservations []resolvedModelObservation
SubscribedThreads map[string]struct{}
}

// resolvedModelObservation links one exact app-server response usage event to
Expand Down
72 changes: 47 additions & 25 deletions internal/codex/daemon_status_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,24 +27,25 @@ type daemonStatusProvider struct {
contexts map[string]*daemonContextState
socketPath string

mu sync.Mutex
connection *websocket.Conn
nextRequestID int64
pending map[int64]chan daemonEnvelope
subscribed map[string]struct{}
reroutedTurns map[daemonTurnKey]string
observations []resolvedModelObservation
nextSequence uint64
lastStatusAt time.Time
statusThreads map[string]struct{}
statuses map[string]sessionRuntimeStatus
threadSettings map[string]QuotaSession
settingsVersions map[string]uint64
settingsVersion uint64
settingsSignal chan struct{}
settingsMu sync.Mutex
settingsClosed bool
writeMu sync.Mutex
mu sync.Mutex
connection *websocket.Conn
nextRequestID int64
pending map[int64]chan daemonEnvelope
subscribed map[string]struct{}
reroutedTurns map[daemonTurnKey]string
observations []resolvedModelObservation
nextSequence uint64
lastStatusAt time.Time
statusThreads map[string]struct{}
statuses map[string]sessionRuntimeStatus
threadSettings map[string]QuotaSession
threadDirectories map[string]string
settingsVersions map[string]uint64
settingsVersion uint64
settingsSignal chan struct{}
settingsMu sync.Mutex
settingsClosed bool
writeMu sync.Mutex
}

type daemonTurnKey struct {
Expand Down Expand Up @@ -80,14 +81,17 @@ func (p *daemonStatusProvider) Fetch(ctx context.Context, threadIDs []string) (s
if _, ok := loaded[threadID]; !ok || p.isSubscribed(threadID) {
continue
}
var resumed json.RawMessage
var resumed struct{ Cwd string }
if err := p.request(ctx, "thread/resume", map[string]any{
"threadId": threadID, "excludeTurns": true,
}, &resumed); err != nil {
continue
}
p.mu.Lock()
p.subscribed[threadID] = struct{}{}
if filepath.IsAbs(resumed.Cwd) {
p.threadDirectories[threadID] = resumed.Cwd
}
p.mu.Unlock()
}
}
Expand Down Expand Up @@ -134,23 +138,28 @@ func (p *daemonStatusProvider) cachedStatusSnapshot(threadIDs []string, now time

func (p *daemonStatusProvider) statusSnapshotLocked(threadIDs []string) sessionDaemonSnapshot {
statuses := make(map[string]sessionRuntimeStatus, len(threadIDs))
directories := make(map[string]string, len(threadIDs))
for _, threadID := range threadIDs {
if status, ok := p.statuses[threadID]; ok {
if state := p.contexts[threadID]; status == sessionRuntimeIdle && state != nil && state.completed {
status = sessionRuntimeComplete
}
statuses[threadID] = status
if cwd := p.threadDirectories[threadID]; cwd != "" {
directories[threadID] = cwd
}
}
}
subscribed := make(map[string]struct{}, len(p.subscribed))
for threadID := range p.subscribed {
subscribed[threadID] = struct{}{}
}
return sessionDaemonSnapshot{
Contexts: daemonContextSnapshot(p.contexts, threadIDs, statuses),
Statuses: statuses,
ModelObservations: append([]resolvedModelObservation(nil), p.observations...),
SubscribedThreads: subscribed,
Contexts: daemonContextSnapshot(p.contexts, threadIDs, statuses),
WorkingDirectories: directories,
Statuses: statuses,
ModelObservations: append([]resolvedModelObservation(nil), p.observations...),
SubscribedThreads: subscribed,
}
}

Expand Down Expand Up @@ -203,6 +212,7 @@ func (p *daemonStatusProvider) unsubscribeMissing(ctx context.Context, threadIDs
p.mu.Lock()
delete(p.subscribed, threadID)
delete(p.contexts, threadID)
delete(p.threadDirectories, threadID)
for key := range p.reroutedTurns {
if key.threadID == threadID {
delete(p.reroutedTurns, key)
Expand Down Expand Up @@ -245,6 +255,7 @@ func (p *daemonStatusProvider) ensureConnected(ctx context.Context) error {
p.subscribed = make(map[string]struct{})
p.reroutedTurns = make(map[daemonTurnKey]string)
p.threadSettings = make(map[string]QuotaSession)
p.threadDirectories = make(map[string]string)
p.settingsVersions = make(map[string]uint64)
p.settingsSignal = make(chan struct{})
p.mu.Unlock()
Expand Down Expand Up @@ -415,16 +426,26 @@ func (p *daemonStatusProvider) handleNotification(method string, params json.Raw
var notification struct {
ThreadID string `json:"threadId"`
ThreadSettings struct {
Cwd string `json:"cwd"`
Model string `json:"model"`
Effort string `json:"effort"`
ServiceTier *string `json:"serviceTier"`
} `json:"threadSettings"`
}
if json.Unmarshal(params, &notification) != nil || notification.ThreadID == "" ||
notification.ThreadSettings.Model == "" || notification.ThreadSettings.Effort == "" {
if json.Unmarshal(params, &notification) != nil || notification.ThreadID == "" {
return
}
p.mu.Lock()
if filepath.IsAbs(notification.ThreadSettings.Cwd) {
if p.threadDirectories == nil {
p.threadDirectories = map[string]string{}
}
p.threadDirectories[notification.ThreadID] = notification.ThreadSettings.Cwd
}
if notification.ThreadSettings.Model == "" || notification.ThreadSettings.Effort == "" {
p.mu.Unlock()
return
}
if p.threadSettings == nil {
p.threadSettings = make(map[string]QuotaSession)
}
Expand Down Expand Up @@ -516,6 +537,7 @@ func (p *daemonStatusProvider) disconnect(connection *websocket.Conn) {
p.statusThreads = nil
p.statuses = nil
p.threadSettings = nil
p.threadDirectories = nil
if p.settingsSignal != nil {
close(p.settingsSignal)
p.settingsSignal = nil
Expand Down
18 changes: 12 additions & 6 deletions internal/codex/live_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,13 @@ type LiveTurnTiming struct {
// sessions. It also extracts bounded display-only replies and request context;
// reasoning and arbitrary tool output are never retained.
type LiveUsageReader struct {
sessionNames map[string]string
nameIndexInfo os.FileInfo
daemonContexts map[string]SessionContext
SessionsRoot string
WriterLocksRoot string
statusProvider sessionStatusProvider
sessionNames map[string]string
nameIndexInfo os.FileInfo
daemonContexts map[string]SessionContext
daemonDirectories map[string]string
SessionsRoot string
WriterLocksRoot string
statusProvider sessionStatusProvider

mu sync.Mutex
initialized bool
Expand Down Expand Up @@ -340,13 +341,15 @@ func (r *LiveUsageReader) fetchTokenUsage(ctx context.Context, forceFullDiscover

exactStatuses := map[string]sessionRuntimeStatus(nil)
r.daemonContexts = nil
r.daemonDirectories = nil
appServerUp := false
r.daemonSubscribedThreads = nil
if r.statusProvider != nil {
if daemonSnapshot, exact := r.statusProvider.Fetch(ctx, r.observedThreadIDs(now)); exact {
appServerUp = true
exactStatuses = daemonSnapshot.Statuses
r.daemonContexts = daemonSnapshot.Contexts
r.daemonDirectories = daemonSnapshot.WorkingDirectories
r.ingestResolvedModelObservations(daemonSnapshot.ModelObservations)
r.daemonSubscribedThreads = daemonSnapshot.SubscribedThreads
}
Expand Down Expand Up @@ -1278,6 +1281,9 @@ func (r *LiveUsageReader) sessionSnapshots(now time.Time, liveWriters map[string
if root := byID[session.ID]; root != nil && !session.Unattributed {
session.ModelSettings = root.modelSettings
}
if cwd := r.daemonDirectories[session.ID]; cwd != "" {
session.WorkingDirectory = cwd
}
session.Working = groupWorking[session.ID]
if session.Attention == SessionAttentionComplete && groupWorking[session.ID] {
session.Attention = SessionAttentionNone
Expand Down
24 changes: 23 additions & 1 deletion internal/codex/live_usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,7 @@ func TestLiveUsageReaderFinalizesRequestedModelWhenDaemonHasNoReroute(t *testing

type stubSessionStatusProvider struct {
statuses map[string]sessionRuntimeStatus
directories map[string]string
observations []resolvedModelObservation
subscribed map[string]struct{}
unavailable bool
Expand All @@ -737,7 +738,7 @@ func (s stubSessionStatusProvider) Fetch(context.Context, []string) (sessionDaem
return sessionDaemonSnapshot{}, false
}
return sessionDaemonSnapshot{
Statuses: s.statuses, ModelObservations: s.observations, SubscribedThreads: s.subscribed,
Statuses: s.statuses, ModelObservations: s.observations, SubscribedThreads: s.subscribed, WorkingDirectories: s.directories,
}, true
}

Expand Down Expand Up @@ -1281,3 +1282,24 @@ func TestRolloutHelpers(t *testing.T) {
t.Fatal("non-event token payload was accepted")
}
}

func TestLiveUsageDirectoryFollowsRuntimeAndFallsBackAfterDisconnect(t *testing.T) {
home := t.TempDir()
now := time.Now()
path := testRolloutPath(t, home, now, "directory")
writeRollout(t, path, sessionMetaLine("directory", `"cli"`, "/original", nil)+"\n")
reader, err := NewLiveUsageReader(home)
if err != nil {
t.Fatal(err)
}
reader.statusProvider = stubSessionStatusProvider{statuses: map[string]sessionRuntimeStatus{"directory": sessionRuntimeIdle}, directories: map[string]string{"directory": "/changed"}}
usage, err := reader.FetchTokenUsage(context.Background())
if err != nil || len(usage.Sessions) != 1 || usage.Sessions[0].WorkingDirectory != "/changed" {
t.Fatal("stale displayed directory", usage, err)
}
reader.statusProvider = stubSessionStatusProvider{unavailable: true}
usage, err = reader.FetchTokenUsage(context.Background())
if err != nil || usage.Sessions[0].WorkingDirectory != "/original" {
t.Fatal("stale runtime directory survived disconnect", usage, err)
}
}
61 changes: 53 additions & 8 deletions internal/codex/session_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,52 @@ package codex
import (
"context"
"errors"
"net/url"
"strings"
"unicode/utf8"
)

const (
SessionRenameInputLimit = 512
SessionDirectoryInputLimit = 1024
// One UTF-8 rune can take four bytes, each percent-encoded as three bytes.
SessionCommandPathLimit = len("rename/") + SessionDirectoryInputLimit*utf8.UTFMax*3
)

// ValidSessionCommandPath shares the encoded transport bound and decoded editor
// limits between the daemon adapter and browser control. Other command families
// keep their existing 2 KiB transport limit.
func ValidSessionCommandPath(path string) bool {
if len(path) > SessionCommandPathLimit || !utf8.ValidString(path) {
return false
}
command := strings.TrimPrefix(strings.TrimSpace(path), "/")
for _, family := range []struct {
name string
limit int
}{
{"rename", SessionRenameInputLimit},
{"cd", SessionDirectoryInputLimit},
} {
value, encoded := strings.CutPrefix(command, family.name+"/")
if !encoded {
var direct bool
value, direct = strings.CutPrefix(command, family.name+" ")
if !direct {
continue
}
} else {
var err error
value, err = url.PathUnescape(value)
if err != nil {
return false
}
}
return utf8.ValidString(value) && utf8.RuneCountInString(value) <= family.limit
}
return len(path) <= 2048
}

// Command catalogues are interface-neutral, live and memory-only. Front ends
// render metadata; they never manufacture RPC methods or permission profiles.
type SessionCommandChoice struct {
Expand All @@ -20,14 +63,16 @@ type SessionCommandChoice struct {
}

type SessionCommandMenu struct {
Title string `json:"title"`
Help string `json:"help"`
Path string `json:"path"`
Revision string `json:"revision"`
Multiple bool `json:"multiple,omitempty"`
Input bool `json:"input,omitempty"`
Value string `json:"value,omitempty"`
Choices []SessionCommandChoice `json:"choices"`
Title string `json:"title"`
Help string `json:"help"`
Path string `json:"path"`
Revision string `json:"revision"`
Multiple bool `json:"multiple,omitempty"`
Input bool `json:"input,omitempty"`
InputLabel string `json:"inputLabel,omitempty"`
InputLimit int `json:"inputLimit,omitempty"`
Value string `json:"value,omitempty"`
Choices []SessionCommandChoice `json:"choices"`
}

type SessionCommandsClient interface {
Expand Down
36 changes: 35 additions & 1 deletion internal/codex/session_commands_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package codex

import "testing"
import (
"net/url"
"strings"
"testing"
)

func TestSessionCommandLiteral(t *testing.T) {
for _, tc := range []struct {
Expand All @@ -17,3 +21,33 @@ func TestSessionCommandLiteral(t *testing.T) {
}
}
}

func TestSessionCommandPathDecodedLimits(t *testing.T) {
for _, tc := range []struct {
name, path string
valid bool
}{
{"CJK rename boundary", "rename/" + url.PathEscape(strings.Repeat("界", 512)), true},
{"four-byte rename boundary", "/rename/" + url.PathEscape(strings.Repeat("😀", 512)), true},
{"direct Unicode rename", "/rename " + strings.Repeat("界", 512), true},
{"directory spaces boundary", "cd/" + url.PathEscape(strings.Repeat(" ", 1024)), true},
{"four-byte directory boundary", "/cd/" + url.PathEscape(strings.Repeat("😀", 1024)), true},
{"direct Unicode directory", "/cd " + strings.Repeat("界", 1024), true},
{"rename over limit", "rename/" + url.PathEscape(strings.Repeat("界", 513)), false},
{"direct rename over limit", "/rename " + strings.Repeat("界", 513), false},
{"directory over limit", "cd/" + url.PathEscape(strings.Repeat("😀", 1025)), false},
{"direct directory over limit", "/cd " + strings.Repeat("界", 1025), false},
{"bad escaping", "rename/%zz", false},
{"invalid encoded UTF-8", "cd/%FF", false},
{"invalid plain UTF-8", "rename/\xff", false},
{"other command boundary", "model/" + strings.Repeat("x", 2042), true},
{"other command over limit", "model/" + strings.Repeat("x", 2043), false},
{"oversized transport", strings.Repeat("x", SessionCommandPathLimit+1), false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := ValidSessionCommandPath(tc.path); got != tc.valid {
t.Fatalf("valid=%v want %v (encoded bytes=%d)", got, tc.valid, len(tc.path))
}
})
}
}
Loading
Loading