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:
dj-oyu 2026-02-22 08:22:38 +09:00
parent df47980070
commit 53e2617a4e
10 changed files with 359 additions and 10 deletions

View file

@ -308,7 +308,7 @@ picoclaw gateway
#### 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:**
@ -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 |
| **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 |
**Setup — Tailscale (recommended for self-hosting):**

View file

@ -333,6 +333,22 @@ func (p *agentLoopDataProvider) GetSessionStats() *stats.Stats {
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.
type telegramCommandSender struct {
bus *bus.MessageBus

View file

@ -424,6 +424,11 @@ func (cb *ContextBuilder) SetPlanStatus(status string) error {
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.
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
allSkills := cb.skillsLoader.ListSkills()

View file

@ -86,6 +86,7 @@ type AgentLoop struct {
planStartPending bool // set by /plan start to trigger LLM execution
sessionLocks sync.Map // sessionKey → *sessionSemaphore
activeTasks sync.Map // sessionKey → *activeTask
sessions *SessionTracker
}
// processOptions configures how a message is processed
@ -137,6 +138,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
summarizing: sync.Map{},
fallback: fallbackChain,
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)
// 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 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
sb.WriteString(messages[0].Content)
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
assistantMsg := providers.Message{
Role: "assistant",
@ -1988,6 +2012,11 @@ func (al *AgentLoop) GetPlanPhases() []PlanPhase {
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.
func (al *AgentLoop) GetSessionStats() *stats.Stats {
if al.stats == nil {
@ -2552,7 +2581,7 @@ func (al *AgentLoop) expandPlanCommand(msg bus.InboundMessage) (expanded string,
}
// Write the interview seed
seed := BuildInterviewSeed(task)
seed := BuildInterviewSeed(task, agent.Workspace)
if err := agent.ContextBuilder.WriteMemory(seed); err != nil {
return "", "", false
}

View file

@ -141,6 +141,7 @@ var (
rePhaseHeader = regexp.MustCompile(`(?m)^## Phase (\d+):\s*(.*)`)
reStepDone = regexp.MustCompile(`(?m)^- \[x\] `)
reStepTodo = regexp.MustCompile(`(?m)^- \[ \] `)
reWorkDir = regexp.MustCompile(`(?m)^> WorkDir:\s*(.+)`)
)
// 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 ----------
// 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.
const interviewSeedTemplate = `# Active Plan
> Task: %s
> WorkDir: %s
> Status: interviewing
> Phase: 1
`
// BuildInterviewSeed creates the initial plan seed for a given task description.
func BuildInterviewSeed(task string) string {
return fmt.Sprintf(interviewSeedTemplate, task)
func BuildInterviewSeed(task, workDir string) string {
return fmt.Sprintf(interviewSeedTemplate, task, workDir)
}
// GetInterviewContext returns context for injection during the interviewing phase.

View file

@ -458,7 +458,7 @@ func TestGetMemoryContext_RegularMemory(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") {
t.Error("expected '# Active Plan' header")

View 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
}

View 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)
}
}

View file

@ -44,11 +44,22 @@ type PlanInfo struct {
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.
type DataProvider interface {
ListSkills() []skills.SkillInfo
GetPlanInfo() PlanInfo
GetSessionStats() *stats.Stats
GetActiveSessions() []SessionInfo
}
// 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/plan", h.requireAuth(h.apiPlan))
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))
}
@ -116,6 +128,14 @@ func (h *Handler) apiPlan(w http.ResponseWriter, r *http.Request) {
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) {
s := h.provider.GetSessionStats()
if s == nil {

View file

@ -657,6 +657,44 @@ async function loadSkills() {
}
// ── 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() {
const loading = document.getElementById('session-loading');
const el = document.getElementById('session-content');
@ -665,17 +703,23 @@ async function loadSession() {
el.style.display = 'none';
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';
el.style.display = 'block';
var html = renderActiveSessions(sessions);
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;
}
const since = data.since ? new Date(data.since).toLocaleDateString() : 'N/A';
el.innerHTML =
html +=
'<div class="card">' +
'<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>' +
@ -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">Completion Tokens</span><span class="stat-value">' + formatTokens(data.total_completion_tokens || 0) + '</span></div>' +
'</div>';
el.innerHTML = html;
} catch (e) {
loading.textContent = 'Failed to load session.';
loading.style.display = 'block';