From c95dee28ef554a7c1083c5e79ac229964bd0bee5 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:22:14 +0900 Subject: [PATCH] feat(session): add /session CLI commands and Mini App graph UI (TASKS-3 Phase 3) - Refactor handleSessionCommand into subcommand dispatcher (list/graph/fork/reset) - Add SessionGraphNode type and GetSessionGraph() for Mini App API - Add /miniapp/api/sessions/graph endpoint with SSE integration - Add session tree rendering in Mini App frontend - Mark TASKS-3 fully complete (Phase 0-3) Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 7 +- cmd/picoclaw/internal/gateway/helpers.go | 52 +++++ pkg/agent/loop.go | 246 ++++++++++++++++++++--- pkg/miniapp/api.go | 15 +- pkg/miniapp/miniapp.go | 1 + pkg/miniapp/miniapp_test.go | 4 + pkg/miniapp/static/index.html | 73 +++++++ pkg/miniapp/types.go | 27 +++ todo/TASKS-3.md | 18 +- 9 files changed, 407 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1e08ac3a3..fd8039ab1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ Lint: `golangci-lint run` - **Sandbox/Spawn**: `pkg/tools/sandbox.go`, `pkg/tools/spawn.go` 実装済み。 - **AgentReporter**: `orch.AgentReporter` / `orch.Noop` / `orch.Broadcaster` で統一。main/heartbeat/subagent 全セッションが同一 Broadcaster に発火。Mini App は `agentLoop.GetOrchBroadcaster()` → `handler.SetOrchBroadcaster()` で受信。 -## Session DAG (Phase 0–2 実装済み) +## Session DAG (Phase 0–3 実装済み) - **SQLite SessionStore**: `pkg/session/sqlite.go` — `modernc.org/sqlite` (CGO不要)、WAL モード、`sessions` + `turns` テーブル - **SessionStore interface**: `pkg/session/store.go` — Create/Get/List/Append/Turns/Compact/Fork/Prune 等15メソッド @@ -37,7 +37,8 @@ Lint: `golangci-lint run` - **配線**: `pkg/agent/instance.go` の `Sessions` 型が `*LegacyAdapter` に変更。`sessions.db` を workspace 直下に生成 - **SessionRecorder**: `pkg/tools/session_recorder.go` (interface) + `pkg/agent/session_recorder.go` (impl) — SubagentManager から Fork/Turn/Completion/Report を記録 - **Prune**: 起動時 `store.Prune(7d)` + `flushLoop` 内 6h 定期 prune。`AgentLoop.gcLoop` で 30分毎に idle `sessionLocks` を GC -- **Phase 3以降**: UI/CLI — Mini App セッショングラフ可視化、`/session` コマンド → `todo/TASKS-3.md` 参照 +- **CLI `/session` コマンド**: `list` / `graph` / `fork [label]` / `reset` サブコマンド。default は DAG summary + token stats +- **Mini App Session Graph**: `/miniapp/api/sessions/graph` endpoint。SSE `session` event に `graph` 含む。フロントエンドで tree rendering ## コードの匂い — チェックリスト @@ -61,6 +62,6 @@ Lint: `golangci-lint run` |---|---| | [`todo/TASKS-1.md`](todo/TASKS-1.md) | **Memory & Performance Optimization** — MemoryStore キャッシュ、FunctionCall/ToolDefinition 型整理、stats フラッシュ最適化 | | [`todo/TASKS-2.md`](todo/TASKS-2.md) | **Subagent Orchestration (Container Model)** — SubagentContainer、Orchestrator、Presets enforcement、Subagent Plan Mode | -| [`todo/TASKS-3.md`](todo/TASKS-3.md) | **Session DAG (SQLite Store)** — セッション管理の SQLite 移行、Turn ベース線形+セッション間 DAG、Fork/Report フロー | +| [`todo/TASKS-3.md`](todo/TASKS-3.md) | ~~**Session DAG (SQLite Store)**~~ ✅ 実装済み(Phase 0–3: SQLite SessionStore、LegacyAdapter、Fork/Report、CompactOldTurns、`/session` CLI コマンド、Mini App グラフ UI) | | [`todo/TASKS-4.md`](todo/TASKS-4.md) | **Mini App & Static Serving** — 静的配信の汎用化、バンドラ導入、フロントエンドテスト追加 | | [`todo/TASKS-5.md`](todo/TASKS-5.md) | ~~**Heartbeat Worktree Management**~~ ✅ 実装済み(`/plan worktrees` の `list/inspect/merge/dispose`、安全化した `PruneOrphaned`、Mini App `/miniapp/api/worktrees` + Git タブ UI) | diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index cb823f60e..6efec6a0f 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -425,6 +425,58 @@ func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo { return result } +func (p *agentLoopDataProvider) GetSessionGraph() *miniapp.SessionGraphData { + nodes := p.loop.GetSessionGraph() + if len(nodes) == 0 { + return &miniapp.SessionGraphData{ + Nodes: []miniapp.SessionGraphNode{}, + Edges: []miniapp.SessionGraphEdge{}, + } + } + + gNodes := make([]miniapp.SessionGraphNode, 0, len(nodes)) + var edges []miniapp.SessionGraphEdge + + for _, n := range nodes { + sk := gatewayShortKey(n.Key) + label := n.Label + if label == "" { + label = sk + } + gNodes = append(gNodes, miniapp.SessionGraphNode{ + Key: n.Key, + ShortKey: sk, + Label: label, + Status: n.Status, + TurnCount: n.TurnCount, + CreatedAt: n.CreatedAt.Format(time.RFC3339), + UpdatedAt: n.UpdatedAt.Format(time.RFC3339), + Summary: n.Summary, + ForkTurnID: n.ForkTurnID, + }) + if n.ParentKey != "" { + edges = append(edges, miniapp.SessionGraphEdge{ + From: n.ParentKey, + To: n.Key, + ForkTurnID: n.ForkTurnID, + }) + } + } + if edges == nil { + edges = []miniapp.SessionGraphEdge{} + } + return &miniapp.SessionGraphData{Nodes: gNodes, Edges: edges} +} + +// gatewayShortKey abbreviates long session keys for display. +func gatewayShortKey(key string) string { + parts := strings.Split(key, ":") + if len(parts) > 2 { + return strings.Join(parts[2:], ":") + } + return key +} + func (p *agentLoopDataProvider) GetContextInfo() miniapp.ContextInfo { workDir, planWorkDir, workspace, bootstrap := p.loop.GetContextInfo() files := make([]miniapp.BootstrapFileInfo, len(bootstrap)) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index f2cc4f485..8b25379cb 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -3629,7 +3629,7 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) } case "/session": - return al.handleSessionCommand(args), true + return al.handleSessionCommand(args, msg.SessionKey), true case "/skills": return al.handleSkillsCommand(), true @@ -3728,33 +3728,233 @@ func splitChatAndThread(chatID string) (baseChatID string, threadID int) { return baseChatID, threadID } -// handleSessionCommand returns usage statistics or resets them. -func (al *AgentLoop) handleSessionCommand(args []string) string { - if al.stats == nil { - return "Stats tracking is disabled. Start with --stats flag to enable.\nUsage: picoclaw gateway --stats" +// handleSessionCommand dispatches /session subcommands. +func (al *AgentLoop) handleSessionCommand(args []string, sessionKey string) string { + sub := "" + if len(args) > 0 { + sub = strings.ToLower(strings.TrimSpace(args[0])) } - - if len(args) > 0 && args[0] == "reset" { + switch sub { + case "list": + return al.handleSessionList() + case "graph": + return al.handleSessionGraph() + case "fork": + return al.handleSessionFork(args[1:], sessionKey) + case "reset": + if al.stats == nil { + return "Stats tracking is disabled." + } al.stats.Reset() return "Session statistics have been reset." + default: + return al.handleSessionStats() + } +} + +func (al *AgentLoop) handleSessionStats() string { + agent := al.registry.GetDefaultAgent() + store := agent.Sessions.Store() + + // Session DAG summary + sessions, _ := store.List(nil) + var sb strings.Builder + fmt.Fprintf(&sb, "Sessions: %d in store\n", len(sessions)) + if len(sessions) > 0 { + active, completed := 0, 0 + for _, s := range sessions { + switch s.Status { + case "active": + active++ + case "completed": + completed++ + } + } + fmt.Fprintf(&sb, " active=%d completed=%d\n", active, completed) + } + sb.WriteString("\nUse: /session list | graph | fork [label]\n") + + // Token stats if available + if al.stats != nil { + s := al.stats.GetStats() + fmt.Fprintf(&sb, + "\nToken Stats — Today (%s):\n Prompts: %d LLM calls: %d Tokens: %s (in: %s, out: %s)\n"+ + "All time (since %s):\n Prompts: %d LLM calls: %d Tokens: %s (in: %s, out: %s)", + s.Today.Date, + s.Today.Prompts, + s.Today.Requests, + stats.FormatTokenCount(s.Today.TotalTokens), + stats.FormatTokenCount(s.Today.PromptTokens), + stats.FormatTokenCount(s.Today.CompletionTokens), + s.Since.Format("2006-01-02"), + s.TotalPrompts, + s.TotalRequests, + stats.FormatTokenCount(s.TotalTokens), + stats.FormatTokenCount(s.TotalPromptTokens), + stats.FormatTokenCount(s.TotalCompletionTokens), + ) + } + return sb.String() +} + +// shortSessionKey truncates long session keys for display. +func shortSessionKey(key string) string { + parts := strings.Split(key, ":") + if len(parts) > 2 { + return strings.Join(parts[2:], ":") + } + return key +} + +func (al *AgentLoop) handleSessionList() string { + agent := al.registry.GetDefaultAgent() + store := agent.Sessions.Store() + sessions, err := store.List(nil) + if err != nil { + return fmt.Sprintf("Error listing sessions: %v", err) + } + if len(sessions) == 0 { + return "No sessions in store." } - s := al.stats.GetStats() - return fmt.Sprintf( - "Session Statistics\n\nToday (%s):\n Prompts: %d\n LLM calls: %d\n Tokens: %s (in: %s, out: %s)\n\nAll time (since %s):\n Prompts: %d\n LLM calls: %d\n Tokens: %s (in: %s, out: %s)", - s.Today.Date, - s.Today.Prompts, - s.Today.Requests, - stats.FormatTokenCount(s.Today.TotalTokens), - stats.FormatTokenCount(s.Today.PromptTokens), - stats.FormatTokenCount(s.Today.CompletionTokens), - s.Since.Format("2006-01-02"), - s.TotalPrompts, - s.TotalRequests, - stats.FormatTokenCount(s.TotalTokens), - stats.FormatTokenCount(s.TotalPromptTokens), - stats.FormatTokenCount(s.TotalCompletionTokens), - ) + var sb strings.Builder + fmt.Fprintf(&sb, "Sessions (%d)\n", len(sessions)) + for _, s := range sessions { + age := time.Since(s.UpdatedAt).Truncate(time.Second) + label := s.Label + if label == "" { + label = shortSessionKey(s.Key) + } + parent := "" + if s.ParentKey != "" { + parent = " parent=" + shortSessionKey(s.ParentKey) + } + fmt.Fprintf(&sb, "- %s [%s] (%s) turns=%d%s\n", + label, s.Status, age, s.TurnCount, parent) + } + return sb.String() +} + +func (al *AgentLoop) handleSessionGraph() string { + agent := al.registry.GetDefaultAgent() + store := agent.Sessions.Store() + sessions, err := store.List(nil) + if err != nil { + return fmt.Sprintf("Error listing sessions: %v", err) + } + if len(sessions) == 0 { + return "No sessions in store." + } + + // Build parent→children map and find roots + byKey := make(map[string]*session.SessionInfo, len(sessions)) + children := make(map[string][]string) + var roots []string + for _, s := range sessions { + byKey[s.Key] = s + if s.ParentKey == "" { + roots = append(roots, s.Key) + } else { + children[s.ParentKey] = append(children[s.ParentKey], s.Key) + } + } + + var sb strings.Builder + sb.WriteString("Session Graph\n") + for i, root := range roots { + last := i == len(roots)-1 + printSessionTree(&sb, root, byKey, children, "", last) + } + return sb.String() +} + +func printSessionTree(sb *strings.Builder, key string, byKey map[string]*session.SessionInfo, children map[string][]string, prefix string, last bool) { + s := byKey[key] + if s == nil { + return + } + + connector := "├── " + if last { + connector = "└── " + } + icon := "●" + if s.Status == "completed" { + icon = "✓" + } + label := s.Label + if label == "" { + label = shortSessionKey(s.Key) + } + fmt.Fprintf(sb, "%s%s%s %s (turns=%d)\n", prefix, connector, icon, label, s.TurnCount) + + childPrefix := prefix + "│ " + if last { + childPrefix = prefix + " " + } + kids := children[key] + for i, childKey := range kids { + printSessionTree(sb, childKey, byKey, children, childPrefix, i == len(kids)-1) + } +} + +func (al *AgentLoop) handleSessionFork(args []string, sessionKey string) string { + if sessionKey == "" { + return "Cannot fork: no active session key." + } + + agent := al.registry.GetDefaultAgent() + store := agent.Sessions.Store() + + label := "fork" + if len(args) > 0 { + label = strings.Join(args, " ") + } + + childKey := sessionKey + ":fork:" + time.Now().Format("20060102T150405") + err := store.Fork(sessionKey, childKey, &session.CreateOpts{Label: label}) + if err != nil { + return fmt.Sprintf("Fork failed: %v", err) + } + return fmt.Sprintf("Forked session\n parent: %s\n child: %s", shortSessionKey(sessionKey), shortSessionKey(childKey)) +} + +// SessionGraphNode represents a session node for the Mini App graph API. +type SessionGraphNode struct { + Key string `json:"key"` + Label string `json:"label"` + Status string `json:"status"` + Summary string `json:"summary"` + ParentKey string `json:"parent_key"` + ForkTurnID string `json:"fork_turn_id"` + TurnCount int `json:"turn_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GetSessionGraph returns all sessions as a flat list of graph nodes. +func (al *AgentLoop) GetSessionGraph() []SessionGraphNode { + agent := al.registry.GetDefaultAgent() + store := agent.Sessions.Store() + sessions, err := store.List(nil) + if err != nil { + return nil + } + nodes := make([]SessionGraphNode, 0, len(sessions)) + for _, s := range sessions { + nodes = append(nodes, SessionGraphNode{ + Key: s.Key, + Label: s.Label, + Status: s.Status, + Summary: s.Summary, + ParentKey: s.ParentKey, + ForkTurnID: s.ForkTurnID, + TurnCount: s.TurnCount, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + }) + } + return nodes } // expandSkillCommand detects "/skill [message]" and returns: diff --git a/pkg/miniapp/api.go b/pkg/miniapp/api.go index 69c90e56a..01d88703f 100644 --- a/pkg/miniapp/api.go +++ b/pkg/miniapp/api.go @@ -161,6 +161,17 @@ func (h *Handler) apiWorktrees(w http.ResponseWriter, r *http.Request) { } } +func (h *Handler) apiSessionGraph(w http.ResponseWriter, r *http.Request) { + graph := h.provider.GetSessionGraph() + if graph == nil { + graph = &SessionGraphData{ + Nodes: []SessionGraphNode{}, + Edges: []SessionGraphEdge{}, + } + } + writeJSON(w, graph) +} + func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) @@ -220,7 +231,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) { // Send initial state immediately sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan) sendSSEIfChanged(w, flusher, "session", - map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()}, + map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions(), "graph": h.provider.GetSessionGraph()}, &lastSession) sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills) sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev) @@ -236,7 +247,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) { case <-ch: sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan) sendSSEIfChanged(w, flusher, "session", - map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions()}, + map[string]any{"stats": h.provider.GetSessionStats(), "sessions": h.provider.GetActiveSessions(), "graph": h.provider.GetSessionGraph()}, &lastSession) sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills) sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev) diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go index 7ed3c0403..f7e21fc80 100644 --- a/pkg/miniapp/miniapp.go +++ b/pkg/miniapp/miniapp.go @@ -72,6 +72,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { 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/sessions/graph", h.requireAuth(h.apiSessionGraph)) mux.HandleFunc("/miniapp/api/command", h.requireAuth(h.apiCommand)) mux.HandleFunc("/miniapp/api/context", h.requireAuth(h.apiContext)) mux.HandleFunc("/miniapp/api/prompt", h.requireAuth(h.apiPrompt)) diff --git a/pkg/miniapp/miniapp_test.go b/pkg/miniapp/miniapp_test.go index 218927756..368561980 100644 --- a/pkg/miniapp/miniapp_test.go +++ b/pkg/miniapp/miniapp_test.go @@ -192,6 +192,8 @@ func (m *mockDataProvider) GetActiveSessions() []SessionInfo { return []SessionInfo{} } +func (m *mockDataProvider) GetSessionGraph() *SessionGraphData { return nil } + func (m *mockDataProvider) GetGitRepos() []GitRepoSummary { return nil } @@ -487,6 +489,8 @@ func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo { return []SessionInfo{} } +func (m *mutatingDataProvider) GetSessionGraph() *SessionGraphData { return nil } + func (m *mutatingDataProvider) GetGitRepos() []GitRepoSummary { return nil } diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index 13e46de78..47237c1a3 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -873,6 +873,25 @@ .orch-dot { display:inline-block; width:6px; height:6px; border-radius:50%; background:var(--hint); margin-right:4px; vertical-align:middle; } .orch-dot.on { background:#4ade80; } + /* Session graph tree */ + .session-tree { padding:0; margin:0; list-style:none; } + .session-tree-node { position:relative; padding:4px 0 4px 20px; font-size:13px; } + .session-tree-node::before { + content:''; position:absolute; left:0; top:0; bottom:0; + border-left:1px solid var(--secondary-bg); + } + .session-tree-node::after { + content:''; position:absolute; left:0; top:14px; width:16px; + border-top:1px solid var(--secondary-bg); + } + .session-tree-node:last-child::before { height:14px; } + .session-tree-children { padding-left:20px; margin:0; list-style:none; } + .session-tree-label { font-weight:600; } + .session-tree-meta { color:var(--hint); font-size:11px; margin-left:6px; } + .session-tree-icon { font-size:10px; margin-right:4px; } + .session-tree-icon.active { color:var(--done); } + .session-tree-icon.completed { color:var(--hint); } + @@ -905,6 +924,7 @@
Loading session...
+
@@ -1553,14 +1573,66 @@ function loadSession() { apiFetch('/miniapp/api/session'), apiFetch('/miniapp/api/sessions').catch(function() { return []; }), apiFetch('/miniapp/api/context').catch(function() { return null; }), + apiFetch('/miniapp/api/sessions/graph').catch(function() { return null; }), ]); }, function(results) { renderSessionFromData(results[1], results[0]); renderContextFromData(results[2]); + renderSessionGraph(results[3]); }); } +function renderSessionGraph(graph) { + var el = document.getElementById('session-graph'); + if (!el) return; + if (!graph || !graph.nodes || graph.nodes.length === 0) { + el.classList.add('hidden'); + return; + } + el.classList.remove('hidden'); + + // Build parent→children map + var childrenMap = {}; + var roots = []; + graph.nodes.forEach(function(n) { + childrenMap[n.key] = []; + }); + graph.edges.forEach(function(e) { + if (childrenMap[e.from]) childrenMap[e.from].push(e.to); + }); + var nodeMap = {}; + graph.nodes.forEach(function(n) { + nodeMap[n.key] = n; + // Check if this node is a root (no incoming edges) + var isChild = graph.edges.some(function(e) { return e.to === n.key; }); + if (!isChild) roots.push(n.key); + }); + + function renderTreeNode(key) { + var n = nodeMap[key]; + if (!n) return ''; + var icon = n.status === 'completed' ? '\u2713' : '\u25CF'; + var iconClass = n.status === 'completed' ? 'completed' : 'active'; + var label = n.label || n.short_key || n.key; + var kids = childrenMap[key] || []; + var childHtml = ''; + if (kids.length > 0) { + childHtml = ''; + } + return '
  • ' + + '' + icon + '' + + '' + escapeHtml(label) + '' + + 'turns=' + n.turn_count + '' + + childHtml + '
  • '; + } + + var html = '
    Session Graph
    ' + + '
      ' + roots.map(renderTreeNode).join('') + '
    '; + el.innerHTML = html; +} + function formatTokens(n) { if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; @@ -1866,6 +1938,7 @@ function connectSSE() { lastSSE.session = Date.now(); var d = JSON.parse(e.data); renderSessionFromData(d.sessions, d.stats); + if (d.graph) renderSessionGraph(d.graph); } catch(err) {} }); eventSource.addEventListener('skills', function(e) { diff --git a/pkg/miniapp/types.go b/pkg/miniapp/types.go index 2e2fd1016..c8d9261a4 100644 --- a/pkg/miniapp/types.go +++ b/pkg/miniapp/types.go @@ -88,12 +88,39 @@ type ContextInfo struct { Bootstrap []BootstrapFileInfo `json:"bootstrap"` } +// SessionGraphData holds the full session DAG for the Mini App. +type SessionGraphData struct { + Nodes []SessionGraphNode `json:"nodes"` + Edges []SessionGraphEdge `json:"edges"` +} + +// SessionGraphNode represents a single session in the graph. +type SessionGraphNode struct { + Key string `json:"key"` + ShortKey string `json:"short_key"` + Label string `json:"label"` + Status string `json:"status"` + TurnCount int `json:"turn_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Summary string `json:"summary,omitempty"` + ForkTurnID string `json:"fork_turn_id,omitempty"` +} + +// SessionGraphEdge represents a parent→child fork relationship. +type SessionGraphEdge struct { + From string `json:"from"` + To string `json:"to"` + ForkTurnID string `json:"fork_turn_id,omitempty"` +} + // 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 + GetSessionGraph() *SessionGraphData GetGitRepos() []GitRepoSummary GetGitRepoDetail(name string) GitInfo GetContextInfo() ContextInfo diff --git a/todo/TASKS-3.md b/todo/TASKS-3.md index 0fcf38948..24e69c437 100644 --- a/todo/TASKS-3.md +++ b/todo/TASKS-3.md @@ -208,16 +208,18 @@ func (tw *TurnWriter) Discard() --- -### Phase 3: UI & Commands +### Phase 3: UI & Commands ✅ -11. **Mini App セッショングラフ可視化** - - WebSocket で session DAG 構造を配信 - - fork/report 関係をグラフとして描画 +11. ✅ **Mini App セッショングラフ可視化** + - `/miniapp/api/sessions/graph` REST endpoint + SSE `session` event に graph 含む + - フロントエンドで tree rendering (CSS + JS) -12. **CLI コマンド** - - `/session list` — アクティブセッション一覧 - - `/session fork` — 現在のセッションを fork - - `/session graph` — DAG 構造をテキスト表示 +12. ✅ **CLI コマンド** + - `/session list` — 全セッション一覧 (ステータス、ターン数、経過時間) + - `/session fork [label]` — 現在のセッションを fork + - `/session graph` — DAG 構造を ASCII tree 表示 + - `/session` (default) — DAG summary + token stats + usage hint + - `/session reset` — stats リセット ---