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:
parent
f6fbeb38d8
commit
c95dee28ef
9 changed files with 407 additions and 36 deletions
|
|
@ -26,7 +26,7 @@ Lint: `golangci-lint run`
|
||||||
- **Sandbox/Spawn**: `pkg/tools/sandbox.go`, `pkg/tools/spawn.go` 実装済み。
|
- **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()` で受信。
|
- **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` テーブル
|
- **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メソッド
|
- **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 直下に生成
|
- **配線**: `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 を記録
|
- **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
|
- **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-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-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-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) |
|
| [`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) |
|
||||||
|
|
|
||||||
|
|
@ -425,6 +425,58 @@ func (p *agentLoopDataProvider) GetActiveSessions() []miniapp.SessionInfo {
|
||||||
return result
|
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 {
|
func (p *agentLoopDataProvider) GetContextInfo() miniapp.ContextInfo {
|
||||||
workDir, planWorkDir, workspace, bootstrap := p.loop.GetContextInfo()
|
workDir, planWorkDir, workspace, bootstrap := p.loop.GetContextInfo()
|
||||||
files := make([]miniapp.BootstrapFileInfo, len(bootstrap))
|
files := make([]miniapp.BootstrapFileInfo, len(bootstrap))
|
||||||
|
|
|
||||||
|
|
@ -3629,7 +3629,7 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "/session":
|
case "/session":
|
||||||
return al.handleSessionCommand(args), true
|
return al.handleSessionCommand(args, msg.SessionKey), true
|
||||||
|
|
||||||
case "/skills":
|
case "/skills":
|
||||||
return al.handleSkillsCommand(), true
|
return al.handleSkillsCommand(), true
|
||||||
|
|
@ -3728,20 +3728,58 @@ func splitChatAndThread(chatID string) (baseChatID string, threadID int) {
|
||||||
return baseChatID, threadID
|
return baseChatID, threadID
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSessionCommand returns usage statistics or resets them.
|
// handleSessionCommand dispatches /session subcommands.
|
||||||
func (al *AgentLoop) handleSessionCommand(args []string) string {
|
func (al *AgentLoop) handleSessionCommand(args []string, sessionKey string) string {
|
||||||
if al.stats == nil {
|
sub := ""
|
||||||
return "Stats tracking is disabled. Start with --stats flag to enable.\nUsage: picoclaw gateway --stats"
|
if len(args) > 0 {
|
||||||
|
sub = strings.ToLower(strings.TrimSpace(args[0]))
|
||||||
|
}
|
||||||
|
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."
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(args) > 0 && args[0] == "reset" {
|
|
||||||
al.stats.Reset()
|
al.stats.Reset()
|
||||||
return "Session statistics have been 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()
|
s := al.stats.GetStats()
|
||||||
return fmt.Sprintf(
|
fmt.Fprintf(&sb,
|
||||||
"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)",
|
"\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.Date,
|
||||||
s.Today.Prompts,
|
s.Today.Prompts,
|
||||||
s.Today.Requests,
|
s.Today.Requests,
|
||||||
|
|
@ -3755,6 +3793,168 @@ func (al *AgentLoop) handleSessionCommand(args []string) string {
|
||||||
stats.FormatTokenCount(s.TotalPromptTokens),
|
stats.FormatTokenCount(s.TotalPromptTokens),
|
||||||
stats.FormatTokenCount(s.TotalCompletionTokens),
|
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."
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
// expandSkillCommand detects "/skill <name> [message]" and returns:
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
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
|
// Send initial state immediately
|
||||||
sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan)
|
sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan)
|
||||||
sendSSEIfChanged(w, flusher, "session",
|
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)
|
&lastSession)
|
||||||
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
|
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
|
||||||
sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev)
|
sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev)
|
||||||
|
|
@ -236,7 +247,7 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
case <-ch:
|
case <-ch:
|
||||||
sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan)
|
sendSSEIfChanged(w, flusher, "plan", h.provider.GetPlanInfo(), &lastPlan)
|
||||||
sendSSEIfChanged(w, flusher, "session",
|
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)
|
&lastSession)
|
||||||
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
|
sendSSEIfChanged(w, flusher, "skills", h.provider.ListSkills(), &lastSkills)
|
||||||
sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev)
|
sendSSEIfChanged(w, flusher, "dev", h.devStatus(), &lastDev)
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
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/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/command", h.requireAuth(h.apiCommand))
|
||||||
mux.HandleFunc("/miniapp/api/context", h.requireAuth(h.apiContext))
|
mux.HandleFunc("/miniapp/api/context", h.requireAuth(h.apiContext))
|
||||||
mux.HandleFunc("/miniapp/api/prompt", h.requireAuth(h.apiPrompt))
|
mux.HandleFunc("/miniapp/api/prompt", h.requireAuth(h.apiPrompt))
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,8 @@ func (m *mockDataProvider) GetActiveSessions() []SessionInfo {
|
||||||
return []SessionInfo{}
|
return []SessionInfo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockDataProvider) GetSessionGraph() *SessionGraphData { return nil }
|
||||||
|
|
||||||
func (m *mockDataProvider) GetGitRepos() []GitRepoSummary {
|
func (m *mockDataProvider) GetGitRepos() []GitRepoSummary {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -487,6 +489,8 @@ func (m *mutatingDataProvider) GetActiveSessions() []SessionInfo {
|
||||||
return []SessionInfo{}
|
return []SessionInfo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mutatingDataProvider) GetSessionGraph() *SessionGraphData { return nil }
|
||||||
|
|
||||||
func (m *mutatingDataProvider) GetGitRepos() []GitRepoSummary {
|
func (m *mutatingDataProvider) GetGitRepos() []GitRepoSummary {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { display:inline-block; width:6px; height:6px; border-radius:50%; background:var(--hint); margin-right:4px; vertical-align:middle; }
|
||||||
.orch-dot.on { background:#4ade80; }
|
.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>
|
</style>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Silkscreen&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Silkscreen&display=swap" rel="stylesheet">
|
||||||
<script src="/miniapp/map.js"></script>
|
<script src="/miniapp/map.js"></script>
|
||||||
|
|
@ -905,6 +924,7 @@
|
||||||
<div id="session" class="panel">
|
<div id="session" class="panel">
|
||||||
<div class="loading" id="session-loading">Loading session...</div>
|
<div class="loading" id="session-loading">Loading session...</div>
|
||||||
<div id="session-content" class="hidden"></div>
|
<div id="session-content" class="hidden"></div>
|
||||||
|
<div id="session-graph" class="hidden"></div>
|
||||||
<div id="context-content"></div>
|
<div id="context-content"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1553,14 +1573,66 @@ function loadSession() {
|
||||||
apiFetch('/miniapp/api/session'),
|
apiFetch('/miniapp/api/session'),
|
||||||
apiFetch('/miniapp/api/sessions').catch(function() { return []; }),
|
apiFetch('/miniapp/api/sessions').catch(function() { return []; }),
|
||||||
apiFetch('/miniapp/api/context').catch(function() { return null; }),
|
apiFetch('/miniapp/api/context').catch(function() { return null; }),
|
||||||
|
apiFetch('/miniapp/api/sessions/graph').catch(function() { return null; }),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
function(results) {
|
function(results) {
|
||||||
renderSessionFromData(results[1], results[0]);
|
renderSessionFromData(results[1], results[0]);
|
||||||
renderContextFromData(results[2]);
|
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) {
|
function formatTokens(n) {
|
||||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
||||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
||||||
|
|
@ -1866,6 +1938,7 @@ function connectSSE() {
|
||||||
lastSSE.session = Date.now();
|
lastSSE.session = Date.now();
|
||||||
var d = JSON.parse(e.data);
|
var d = JSON.parse(e.data);
|
||||||
renderSessionFromData(d.sessions, d.stats);
|
renderSessionFromData(d.sessions, d.stats);
|
||||||
|
if (d.graph) renderSessionGraph(d.graph);
|
||||||
} catch(err) {}
|
} catch(err) {}
|
||||||
});
|
});
|
||||||
eventSource.addEventListener('skills', function(e) {
|
eventSource.addEventListener('skills', function(e) {
|
||||||
|
|
|
||||||
|
|
@ -88,12 +88,39 @@ type ContextInfo struct {
|
||||||
Bootstrap []BootstrapFileInfo `json:"bootstrap"`
|
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.
|
// 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
|
GetActiveSessions() []SessionInfo
|
||||||
|
GetSessionGraph() *SessionGraphData
|
||||||
GetGitRepos() []GitRepoSummary
|
GetGitRepos() []GitRepoSummary
|
||||||
GetGitRepoDetail(name string) GitInfo
|
GetGitRepoDetail(name string) GitInfo
|
||||||
GetContextInfo() ContextInfo
|
GetContextInfo() ContextInfo
|
||||||
|
|
|
||||||
|
|
@ -208,16 +208,18 @@ func (tw *TurnWriter) Discard()
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Phase 3: UI & Commands
|
### Phase 3: UI & Commands ✅
|
||||||
|
|
||||||
11. **Mini App セッショングラフ可視化**
|
11. ✅ **Mini App セッショングラフ可視化**
|
||||||
- WebSocket で session DAG 構造を配信
|
- `/miniapp/api/sessions/graph` REST endpoint + SSE `session` event に graph 含む
|
||||||
- fork/report 関係をグラフとして描画
|
- フロントエンドで tree rendering (CSS + JS)
|
||||||
|
|
||||||
12. **CLI コマンド**
|
12. ✅ **CLI コマンド**
|
||||||
- `/session list` — アクティブセッション一覧
|
- `/session list` — 全セッション一覧 (ステータス、ターン数、経過時間)
|
||||||
- `/session fork` — 現在のセッションを fork
|
- `/session fork [label]` — 現在のセッションを fork
|
||||||
- `/session graph` — DAG 構造をテキスト表示
|
- `/session graph` — DAG 構造を ASCII tree 表示
|
||||||
|
- `/session` (default) — DAG summary + token stats + usage hint
|
||||||
|
- `/session reset` — stats リセット
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue