feat: add SessionTracker for plan coordination and session observability
Independent SessionTracker tracks tool-call activity per session, enabling heartbeat to skip plan preamble when a chat session is actively working in the same directory. Adds /miniapp/api/sessions endpoint and Active Sessions card to the Mini App Session tab. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
39aaacc1a1
commit
8ebfd853c6
10 changed files with 359 additions and 10 deletions
|
|
@ -308,7 +308,7 @@ picoclaw gateway
|
||||||
|
|
||||||
#### Mini App (Dashboard)
|
#### Mini App (Dashboard)
|
||||||
|
|
||||||
PicoClaw includes a Telegram Mini App that provides a GUI dashboard directly inside Telegram. It shows plan progress, available skills, session stats, and lets you send commands without typing.
|
PicoClaw includes a Telegram Mini App that provides a GUI dashboard directly inside Telegram. It shows plan progress, available skills, active sessions, session stats, and lets you send commands without typing.
|
||||||
|
|
||||||
**How it works:**
|
**How it works:**
|
||||||
|
|
||||||
|
|
@ -318,7 +318,7 @@ When Telegram is enabled, PicoClaw automatically registers a "Dashboard" menu bu
|
||||||
|-----|-------------|
|
|-----|-------------|
|
||||||
| **Plan** | View plan phases/steps as a checklist, tap to mark done, start new plans |
|
| **Plan** | View plan phases/steps as a checklist, tap to mark done, start new plans |
|
||||||
| **Skills** | Browse and invoke skills with a message input |
|
| **Skills** | Browse and invoke skills with a message input |
|
||||||
| **Session** | View token usage stats (requires `--stats` flag) |
|
| **Session** | View active sessions and token usage stats (requires `--stats` flag for stats) |
|
||||||
| **Config** | Quick command buttons and custom command input |
|
| **Config** | Quick command buttons and custom command input |
|
||||||
|
|
||||||
**Setup — Tailscale (recommended for self-hosting):**
|
**Setup — Tailscale (recommended for self-hosting):**
|
||||||
|
|
|
||||||
|
|
@ -333,6 +333,22 @@ func (p *agentLoopDataProvider) GetSessionStats() *stats.Stats {
|
||||||
return p.loop.GetSessionStats()
|
return p.loop.GetSessionStats()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo {
|
||||||
|
entries := p.loop.GetActiveSessions()
|
||||||
|
result := make([]miniapp.SessionInfo, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
result[i] = miniapp.SessionInfo{
|
||||||
|
SessionKey: e.SessionKey,
|
||||||
|
Channel: e.Channel,
|
||||||
|
ChatID: e.ChatID,
|
||||||
|
TouchDir: e.TouchDir,
|
||||||
|
LastSeenAt: e.LastSeenAt.Format(time.RFC3339),
|
||||||
|
AgeSec: int(time.Since(e.LastSeenAt).Seconds()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// telegramCommandSender injects Mini App commands into the message bus.
|
// telegramCommandSender injects Mini App commands into the message bus.
|
||||||
type telegramCommandSender struct {
|
type telegramCommandSender struct {
|
||||||
bus *bus.MessageBus
|
bus *bus.MessageBus
|
||||||
|
|
|
||||||
|
|
@ -424,6 +424,11 @@ func (cb *ContextBuilder) SetPlanStatus(status string) error {
|
||||||
return cb.memory.SetStatus(status)
|
return cb.memory.SetStatus(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPlanWorkDir returns the WorkDir from the plan metadata, or "".
|
||||||
|
func (cb *ContextBuilder) GetPlanWorkDir() string {
|
||||||
|
return cb.memory.GetPlanWorkDir()
|
||||||
|
}
|
||||||
|
|
||||||
// GetSkillsInfo returns information about loaded skills.
|
// GetSkillsInfo returns information about loaded skills.
|
||||||
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
|
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
|
||||||
allSkills := cb.skillsLoader.ListSkills()
|
allSkills := cb.skillsLoader.ListSkills()
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ type AgentLoop struct {
|
||||||
planStartPending bool // set by /plan start to trigger LLM execution
|
planStartPending bool // set by /plan start to trigger LLM execution
|
||||||
sessionLocks sync.Map // sessionKey → *sessionSemaphore
|
sessionLocks sync.Map // sessionKey → *sessionSemaphore
|
||||||
activeTasks sync.Map // sessionKey → *activeTask
|
activeTasks sync.Map // sessionKey → *activeTask
|
||||||
|
sessions *SessionTracker
|
||||||
}
|
}
|
||||||
|
|
||||||
// processOptions configures how a message is processed
|
// processOptions configures how a message is processed
|
||||||
|
|
@ -137,6 +138,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
||||||
summarizing: sync.Map{},
|
summarizing: sync.Map{},
|
||||||
fallback: fallbackChain,
|
fallback: fallbackChain,
|
||||||
providerCache: providerCache,
|
providerCache: providerCache,
|
||||||
|
sessions: NewSessionTracker(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -753,8 +755,11 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
|
|
||||||
// 2c. Background plan preamble: append to system prompt (high attention)
|
// 2c. Background plan preamble: append to system prompt (high attention)
|
||||||
// so the LLM knows from the start that it must mark steps [x].
|
// so the LLM knows from the start that it must mark steps [x].
|
||||||
|
// Skip if a chat session is actively working on the plan directory.
|
||||||
if opts.Background && agent.ContextBuilder.HasActivePlan() && agent.ContextBuilder.GetPlanStatus() == "executing" {
|
if opts.Background && agent.ContextBuilder.HasActivePlan() && agent.ContextBuilder.GetPlanStatus() == "executing" {
|
||||||
if len(messages) > 0 && messages[0].Role == "system" {
|
planDir := agent.ContextBuilder.GetPlanWorkDir()
|
||||||
|
skipPreamble := planDir != "" && al.sessions.IsActiveInDir(planDir, "heartbeat")
|
||||||
|
if !skipPreamble && len(messages) > 0 && messages[0].Role == "system" {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(messages[0].Content)
|
sb.WriteString(messages[0].Content)
|
||||||
sb.WriteString("\n\n## Background Execution\n")
|
sb.WriteString("\n\n## Background Execution\n")
|
||||||
|
|
@ -1616,6 +1621,25 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record session activity for heartbeat/plan coordination
|
||||||
|
for _, tc := range normalizedToolCalls {
|
||||||
|
var detectedDir string
|
||||||
|
if tc.Name == "exec" {
|
||||||
|
detectedDir = extractExecProjectDir(tc.Arguments)
|
||||||
|
}
|
||||||
|
if detectedDir == "" {
|
||||||
|
switch tc.Name {
|
||||||
|
case "read_file", "write_file", "edit_file", "append_file", "list_dir":
|
||||||
|
if p, _ := tc.Arguments["path"].(string); p != "" {
|
||||||
|
detectedDir = fileParentRelDir(p, agent.Workspace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if detectedDir != "" {
|
||||||
|
al.sessions.Touch(opts.SessionKey, opts.Channel, opts.ChatID, detectedDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build assistant message with tool calls
|
// Build assistant message with tool calls
|
||||||
assistantMsg := providers.Message{
|
assistantMsg := providers.Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
|
|
@ -1988,6 +2012,11 @@ func (al *AgentLoop) GetPlanPhases() []PlanPhase {
|
||||||
return mem.GetPlanPhases()
|
return mem.GetPlanPhases()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetActiveSessions returns currently active sessions for the mini app API.
|
||||||
|
func (al *AgentLoop) GetActiveSessions() []SessionEntry {
|
||||||
|
return al.sessions.ListActive()
|
||||||
|
}
|
||||||
|
|
||||||
// GetSessionStats returns the current session statistics snapshot, or nil if stats tracking is disabled.
|
// GetSessionStats returns the current session statistics snapshot, or nil if stats tracking is disabled.
|
||||||
func (al *AgentLoop) GetSessionStats() *stats.Stats {
|
func (al *AgentLoop) GetSessionStats() *stats.Stats {
|
||||||
if al.stats == nil {
|
if al.stats == nil {
|
||||||
|
|
@ -2552,7 +2581,7 @@ func (al *AgentLoop) expandPlanCommand(msg bus.InboundMessage) (expanded string,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the interview seed
|
// Write the interview seed
|
||||||
seed := BuildInterviewSeed(task)
|
seed := BuildInterviewSeed(task, agent.Workspace)
|
||||||
if err := agent.ContextBuilder.WriteMemory(seed); err != nil {
|
if err := agent.ContextBuilder.WriteMemory(seed); err != nil {
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,7 @@ var (
|
||||||
rePhaseHeader = regexp.MustCompile(`(?m)^## Phase (\d+):\s*(.*)`)
|
rePhaseHeader = regexp.MustCompile(`(?m)^## Phase (\d+):\s*(.*)`)
|
||||||
reStepDone = regexp.MustCompile(`(?m)^- \[x\] `)
|
reStepDone = regexp.MustCompile(`(?m)^- \[x\] `)
|
||||||
reStepTodo = regexp.MustCompile(`(?m)^- \[ \] `)
|
reStepTodo = regexp.MustCompile(`(?m)^- \[ \] `)
|
||||||
|
reWorkDir = regexp.MustCompile(`(?m)^> WorkDir:\s*(.+)`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// HasActivePlan returns true if MEMORY.md contains an active plan.
|
// HasActivePlan returns true if MEMORY.md contains an active plan.
|
||||||
|
|
@ -408,17 +409,28 @@ func (ms *MemoryStore) AddStep(phase int, desc string) error {
|
||||||
|
|
||||||
// ---------- Selective injection methods ----------
|
// ---------- Selective injection methods ----------
|
||||||
|
|
||||||
|
// GetPlanWorkDir returns the WorkDir from the plan metadata, or "".
|
||||||
|
func (ms *MemoryStore) GetPlanWorkDir() string {
|
||||||
|
content := ms.ReadLongTerm()
|
||||||
|
m := reWorkDir.FindStringSubmatch(content)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
// interviewSeed is the initial content written to MEMORY.md when /plan starts.
|
// interviewSeed is the initial content written to MEMORY.md when /plan starts.
|
||||||
const interviewSeedTemplate = `# Active Plan
|
const interviewSeedTemplate = `# Active Plan
|
||||||
|
|
||||||
> Task: %s
|
> Task: %s
|
||||||
|
> WorkDir: %s
|
||||||
> Status: interviewing
|
> Status: interviewing
|
||||||
> Phase: 1
|
> Phase: 1
|
||||||
`
|
`
|
||||||
|
|
||||||
// BuildInterviewSeed creates the initial plan seed for a given task description.
|
// BuildInterviewSeed creates the initial plan seed for a given task description.
|
||||||
func BuildInterviewSeed(task string) string {
|
func BuildInterviewSeed(task, workDir string) string {
|
||||||
return fmt.Sprintf(interviewSeedTemplate, task)
|
return fmt.Sprintf(interviewSeedTemplate, task, workDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetInterviewContext returns context for injection during the interviewing phase.
|
// GetInterviewContext returns context for injection during the interviewing phase.
|
||||||
|
|
|
||||||
|
|
@ -458,7 +458,7 @@ func TestGetMemoryContext_RegularMemory(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildInterviewSeed(t *testing.T) {
|
func TestBuildInterviewSeed(t *testing.T) {
|
||||||
seed := BuildInterviewSeed("Deploy monitoring stack")
|
seed := BuildInterviewSeed("Deploy monitoring stack", "/home/user/project")
|
||||||
|
|
||||||
if !strings.Contains(seed, "# Active Plan") {
|
if !strings.Contains(seed, "# Active Plan") {
|
||||||
t.Error("expected '# Active Plan' header")
|
t.Error("expected '# Active Plan' header")
|
||||||
|
|
|
||||||
98
pkg/agent/session_tracker.go
Normal file
98
pkg/agent/session_tracker.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionEntry represents an active or recently-active session.
|
||||||
|
type SessionEntry struct {
|
||||||
|
SessionKey string `json:"session_key"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
TouchDir string `json:"touch_dir"`
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionTracker tracks per-session tool-call activity.
|
||||||
|
// Thread-safe; used by AgentLoop for plan coordination and by the mini app API for observability.
|
||||||
|
type SessionTracker struct {
|
||||||
|
entries sync.Map // sessionKey → *SessionEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSessionTracker creates a new tracker.
|
||||||
|
func NewSessionTracker() *SessionTracker {
|
||||||
|
return &SessionTracker{}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionActivityTimeout = 15 * time.Minute
|
||||||
|
|
||||||
|
// Touch records a tool-call activity for a session.
|
||||||
|
// dir is the workspace-relative directory the tool call targeted.
|
||||||
|
// If dir is empty, only LastSeenAt is updated.
|
||||||
|
func (st *SessionTracker) Touch(sessionKey, channel, chatID, dir string) {
|
||||||
|
now := time.Now()
|
||||||
|
val, loaded := st.entries.Load(sessionKey)
|
||||||
|
if loaded {
|
||||||
|
entry := val.(*SessionEntry)
|
||||||
|
entry.LastSeenAt = now
|
||||||
|
if dir != "" {
|
||||||
|
entry.TouchDir = dir
|
||||||
|
}
|
||||||
|
if channel != "" {
|
||||||
|
entry.Channel = channel
|
||||||
|
}
|
||||||
|
if chatID != "" {
|
||||||
|
entry.ChatID = chatID
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st.entries.Store(sessionKey, &SessionEntry{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
Channel: channel,
|
||||||
|
ChatID: chatID,
|
||||||
|
TouchDir: dir,
|
||||||
|
LastSeenAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActiveInDir returns true if any session (excluding those matching excludeKey)
|
||||||
|
// has touched a directory overlapping with dir within sessionActivityTimeout.
|
||||||
|
// Overlap = either is a prefix of the other (parent/child relationship).
|
||||||
|
func (st *SessionTracker) IsActiveInDir(dir, excludeKey string) bool {
|
||||||
|
cutoff := time.Now().Add(-sessionActivityTimeout)
|
||||||
|
active := false
|
||||||
|
st.entries.Range(func(key, val any) bool {
|
||||||
|
if key.(string) == excludeKey {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
entry := val.(*SessionEntry)
|
||||||
|
if entry.LastSeenAt.After(cutoff) && entry.TouchDir != "" &&
|
||||||
|
(strings.HasPrefix(entry.TouchDir, dir) || strings.HasPrefix(dir, entry.TouchDir)) {
|
||||||
|
active = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return active
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActive returns all sessions seen within sessionActivityTimeout,
|
||||||
|
// sorted by LastSeenAt descending (most recent first).
|
||||||
|
func (st *SessionTracker) ListActive() []SessionEntry {
|
||||||
|
cutoff := time.Now().Add(-sessionActivityTimeout)
|
||||||
|
var result []SessionEntry
|
||||||
|
st.entries.Range(func(key, val any) bool {
|
||||||
|
entry := val.(*SessionEntry)
|
||||||
|
if entry.LastSeenAt.After(cutoff) {
|
||||||
|
result = append(result, *entry) // copy
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
return result[i].LastSeenAt.After(result[j].LastSeenAt)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
123
pkg/agent/session_tracker_test.go
Normal file
123
pkg/agent/session_tracker_test.go
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTouch(t *testing.T) {
|
||||||
|
st := NewSessionTracker()
|
||||||
|
|
||||||
|
// Basic touch creates entry
|
||||||
|
st.Touch("sess1", "telegram", "123", "projects/myapp")
|
||||||
|
entries := st.ListActive()
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||||
|
}
|
||||||
|
if entries[0].SessionKey != "sess1" {
|
||||||
|
t.Errorf("expected session_key=sess1, got %s", entries[0].SessionKey)
|
||||||
|
}
|
||||||
|
if entries[0].Channel != "telegram" {
|
||||||
|
t.Errorf("expected channel=telegram, got %s", entries[0].Channel)
|
||||||
|
}
|
||||||
|
if entries[0].TouchDir != "projects/myapp" {
|
||||||
|
t.Errorf("expected touch_dir=projects/myapp, got %s", entries[0].TouchDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch again with new dir overwrites TouchDir
|
||||||
|
st.Touch("sess1", "", "", "projects/other")
|
||||||
|
entries = st.ListActive()
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("expected 1 entry, got %d", len(entries))
|
||||||
|
}
|
||||||
|
if entries[0].TouchDir != "projects/other" {
|
||||||
|
t.Errorf("expected touch_dir=projects/other, got %s", entries[0].TouchDir)
|
||||||
|
}
|
||||||
|
// Channel should remain from first touch
|
||||||
|
if entries[0].Channel != "telegram" {
|
||||||
|
t.Errorf("expected channel=telegram (unchanged), got %s", entries[0].Channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch with empty dir does not overwrite TouchDir
|
||||||
|
st.Touch("sess1", "", "", "")
|
||||||
|
entries = st.ListActive()
|
||||||
|
if entries[0].TouchDir != "projects/other" {
|
||||||
|
t.Errorf("expected touch_dir unchanged, got %s", entries[0].TouchDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsActiveInDir(t *testing.T) {
|
||||||
|
st := NewSessionTracker()
|
||||||
|
|
||||||
|
// Setup: sess1 touches "projects/myapp"
|
||||||
|
st.Touch("sess1", "telegram", "123", "projects/myapp")
|
||||||
|
|
||||||
|
// Same dir, excluding sess1 → false
|
||||||
|
if st.IsActiveInDir("projects/myapp", "sess1") {
|
||||||
|
t.Error("expected false when excluding the only active session")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same dir, excluding different key → true
|
||||||
|
if !st.IsActiveInDir("projects/myapp", "heartbeat") {
|
||||||
|
t.Error("expected true for exact dir match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent dir match: "projects" is prefix of "projects/myapp"
|
||||||
|
if !st.IsActiveInDir("projects", "heartbeat") {
|
||||||
|
t.Error("expected true for parent dir match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Child dir match: "projects/myapp/src" has prefix "projects/myapp"
|
||||||
|
if !st.IsActiveInDir("projects/myapp/src", "heartbeat") {
|
||||||
|
t.Error("expected true for child dir match")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unrelated dir → false
|
||||||
|
if st.IsActiveInDir("other/stuff", "heartbeat") {
|
||||||
|
t.Error("expected false for unrelated dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale entry (manually set LastSeenAt to past)
|
||||||
|
val, _ := st.entries.Load("sess1")
|
||||||
|
entry := val.(*SessionEntry)
|
||||||
|
entry.LastSeenAt = time.Now().Add(-sessionActivityTimeout - time.Minute)
|
||||||
|
|
||||||
|
if st.IsActiveInDir("projects/myapp", "heartbeat") {
|
||||||
|
t.Error("expected false for stale session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListActive(t *testing.T) {
|
||||||
|
st := NewSessionTracker()
|
||||||
|
|
||||||
|
// Add two sessions
|
||||||
|
st.Touch("sess1", "telegram", "123", "projects/a")
|
||||||
|
time.Sleep(5 * time.Millisecond) // ensure different timestamps
|
||||||
|
st.Touch("sess2", "discord", "456", "projects/b")
|
||||||
|
|
||||||
|
entries := st.ListActive()
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most recent first
|
||||||
|
if entries[0].SessionKey != "sess2" {
|
||||||
|
t.Errorf("expected sess2 first (most recent), got %s", entries[0].SessionKey)
|
||||||
|
}
|
||||||
|
if entries[1].SessionKey != "sess1" {
|
||||||
|
t.Errorf("expected sess1 second, got %s", entries[1].SessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sess1 stale
|
||||||
|
val, _ := st.entries.Load("sess1")
|
||||||
|
entry := val.(*SessionEntry)
|
||||||
|
entry.LastSeenAt = time.Now().Add(-sessionActivityTimeout - time.Minute)
|
||||||
|
|
||||||
|
entries = st.ListActive()
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("expected 1 active entry after stale, got %d", len(entries))
|
||||||
|
}
|
||||||
|
if entries[0].SessionKey != "sess2" {
|
||||||
|
t.Errorf("expected only sess2, got %s", entries[0].SessionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,11 +44,22 @@ type PlanInfo struct {
|
||||||
Phases []PlanPhase `json:"phases"`
|
Phases []PlanPhase `json:"phases"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SessionInfo represents an active session entry for the API response.
|
||||||
|
type SessionInfo struct {
|
||||||
|
SessionKey string `json:"session_key"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
TouchDir string `json:"touch_dir"`
|
||||||
|
LastSeenAt string `json:"last_seen_at"`
|
||||||
|
AgeSec int `json:"age_sec"`
|
||||||
|
}
|
||||||
|
|
||||||
// DataProvider is the read-only interface to agent state for the Mini App API.
|
// DataProvider is the read-only interface to agent state for the Mini App API.
|
||||||
type DataProvider interface {
|
type DataProvider interface {
|
||||||
ListSkills() []skills.SkillInfo
|
ListSkills() []skills.SkillInfo
|
||||||
GetPlanInfo() PlanInfo
|
GetPlanInfo() PlanInfo
|
||||||
GetSessionStats() *stats.Stats
|
GetSessionStats() *stats.Stats
|
||||||
|
GetActiveSessions() []SessionInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommandSender injects a command into the message bus on behalf of a user.
|
// CommandSender injects a command into the message bus on behalf of a user.
|
||||||
|
|
@ -78,6 +89,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("/miniapp/api/skills", h.requireAuth(h.apiSkills))
|
mux.HandleFunc("/miniapp/api/skills", h.requireAuth(h.apiSkills))
|
||||||
mux.HandleFunc("/miniapp/api/plan", h.requireAuth(h.apiPlan))
|
mux.HandleFunc("/miniapp/api/plan", h.requireAuth(h.apiPlan))
|
||||||
mux.HandleFunc("/miniapp/api/session", h.requireAuth(h.apiSession))
|
mux.HandleFunc("/miniapp/api/session", h.requireAuth(h.apiSession))
|
||||||
|
mux.HandleFunc("/miniapp/api/sessions", h.requireAuth(h.apiSessions))
|
||||||
mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand))
|
mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,6 +128,14 @@ func (h *Handler) apiPlan(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, info)
|
writeJSON(w, info)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) apiSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessions := h.provider.GetActiveSessions()
|
||||||
|
if sessions == nil {
|
||||||
|
sessions = []SessionInfo{}
|
||||||
|
}
|
||||||
|
writeJSON(w, sessions)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) {
|
||||||
s := h.provider.GetSessionStats()
|
s := h.provider.GetSessionStats()
|
||||||
if s == nil {
|
if s == nil {
|
||||||
|
|
|
||||||
|
|
@ -657,6 +657,44 @@ async function loadSkills() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Session tab ──
|
// ── Session tab ──
|
||||||
|
function formatAge(sec) {
|
||||||
|
if (sec < 60) return sec + 's ago';
|
||||||
|
if (sec < 3600) return Math.floor(sec / 60) + 'm ago';
|
||||||
|
return Math.floor(sec / 3600) + 'h ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortSessionKey(key) {
|
||||||
|
// "agent:default:telegram:123" → "telegram:123"
|
||||||
|
var parts = key.split(':');
|
||||||
|
if (parts.length > 2) return parts.slice(2).join(':');
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActiveSessions(sessions) {
|
||||||
|
if (!sessions || sessions.length === 0) {
|
||||||
|
return '<div class="card">' +
|
||||||
|
'<div class="card-title">Active Sessions</div>' +
|
||||||
|
'<div style="color:var(--hint);font-size:13px">No active sessions</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
var html = '<div class="card"><div class="card-title">Active Sessions</div>';
|
||||||
|
for (var i = 0; i < sessions.length; i++) {
|
||||||
|
var s = sessions[i];
|
||||||
|
var touchDir = s.touch_dir || '\u2014';
|
||||||
|
html += '<div style="padding:6px 0;border-bottom:1px solid var(--secondary-bg)">' +
|
||||||
|
'<div style="display:flex;align-items:center;gap:6px">' +
|
||||||
|
'<span style="color:var(--done);font-size:10px">\u25CF</span>' +
|
||||||
|
'<span style="font-weight:600;font-size:13px">' + escapeHtml(shortSessionKey(s.session_key)) + '</span>' +
|
||||||
|
'<span style="margin-left:auto;color:var(--hint);font-size:12px">' + formatAge(s.age_sec) + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="color:var(--hint);font-size:12px;padding-left:16px">touch: ' + escapeHtml(touchDir) + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
html += '<button class="refresh-btn" onclick="loadSession()" style="margin-top:8px">Refresh</button>';
|
||||||
|
html += '</div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSession() {
|
async function loadSession() {
|
||||||
const loading = document.getElementById('session-loading');
|
const loading = document.getElementById('session-loading');
|
||||||
const el = document.getElementById('session-content');
|
const el = document.getElementById('session-content');
|
||||||
|
|
@ -665,17 +703,23 @@ async function loadSession() {
|
||||||
el.style.display = 'none';
|
el.style.display = 'none';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch('/miniapp/api/session');
|
const [data, sessions] = await Promise.all([
|
||||||
|
apiFetch('/miniapp/api/session'),
|
||||||
|
apiFetch('/miniapp/api/sessions').catch(function() { return []; }),
|
||||||
|
]);
|
||||||
loading.style.display = 'none';
|
loading.style.display = 'none';
|
||||||
el.style.display = 'block';
|
el.style.display = 'block';
|
||||||
|
|
||||||
|
var html = renderActiveSessions(sessions);
|
||||||
|
|
||||||
if (data.status === 'stats not enabled') {
|
if (data.status === 'stats not enabled') {
|
||||||
el.innerHTML = '<div class="empty-state">Stats tracking not enabled.<br>Start gateway with --stats flag.</div>';
|
html += '<div class="empty-state">Stats tracking not enabled.<br>Start gateway with --stats flag.</div>';
|
||||||
|
el.innerHTML = html;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const since = data.since ? new Date(data.since).toLocaleDateString() : 'N/A';
|
const since = data.since ? new Date(data.since).toLocaleDateString() : 'N/A';
|
||||||
el.innerHTML =
|
html +=
|
||||||
'<div class="card">' +
|
'<div class="card">' +
|
||||||
'<div class="card-title">Today</div>' +
|
'<div class="card-title">Today</div>' +
|
||||||
'<div class="stat-row"><span class="stat-label">Prompts</span><span class="stat-value">' + (data.today?.prompts || 0) + '</span></div>' +
|
'<div class="stat-row"><span class="stat-label">Prompts</span><span class="stat-value">' + (data.today?.prompts || 0) + '</span></div>' +
|
||||||
|
|
@ -690,6 +734,8 @@ async function loadSession() {
|
||||||
'<div class="stat-row"><span class="stat-label">Prompt Tokens</span><span class="stat-value">' + formatTokens(data.total_prompt_tokens || 0) + '</span></div>' +
|
'<div class="stat-row"><span class="stat-label">Prompt Tokens</span><span class="stat-value">' + formatTokens(data.total_prompt_tokens || 0) + '</span></div>' +
|
||||||
'<div class="stat-row"><span class="stat-label">Completion Tokens</span><span class="stat-value">' + formatTokens(data.total_completion_tokens || 0) + '</span></div>' +
|
'<div class="stat-row"><span class="stat-label">Completion Tokens</span><span class="stat-value">' + formatTokens(data.total_completion_tokens || 0) + '</span></div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
|
el.innerHTML = html;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loading.textContent = 'Failed to load session.';
|
loading.textContent = 'Failed to load session.';
|
||||||
loading.style.display = 'block';
|
loading.style.display = 'block';
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue