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 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-04 23:22:14 +09:00
parent f6fbeb38d8
commit c95dee28ef
9 changed files with 407 additions and 36 deletions

View file

@ -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 02 実装済み)
## Session DAG (Phase 03 実装済み)
- **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 03: 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 |

View file

@ -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))

View file

@ -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 <name> [message]" and returns:

View file

@ -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)

View file

@ -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))

View file

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

View file

@ -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); }
</style>
<link href="https://fonts.googleapis.com/css2?family=Silkscreen&display=swap" rel="stylesheet">
<script src="/miniapp/map.js"></script>
@ -905,6 +924,7 @@
<div id="session" class="panel">
<div class="loading" id="session-loading">Loading session...</div>
<div id="session-content" class="hidden"></div>
<div id="session-graph" class="hidden"></div>
<div id="context-content"></div>
</div>
@ -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 = '<ul class="session-tree-children">' +
kids.map(renderTreeNode).join('') + '</ul>';
}
return '<li class="session-tree-node">' +
'<span class="session-tree-icon ' + iconClass + '">' + icon + '</span>' +
'<span class="session-tree-label">' + escapeHtml(label) + '</span>' +
'<span class="session-tree-meta">turns=' + n.turn_count + '</span>' +
childHtml + '</li>';
}
var html = '<div class="card glass"><div class="card-title">Session Graph</div>' +
'<ul class="session-tree">' + roots.map(renderTreeNode).join('') + '</ul></div>';
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) {

View file

@ -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

View file

@ -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 リセット
---