Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ agentsview auto-discovers sessions from all of these:
| Pi | `~/.pi/agent/sessions/` |
| Qwen Code | `~/.qwen/projects/` |
| OpenClaw | `~/.openclaw/agents/` |
| QClaw | `~/.qclaw/agents/` |
| Kimi | `~/.kimi/sessions/` |
| Kiro CLI | `~/.kiro/sessions/cli/`, `~/.local/share/kiro-cli/` |
| Kiro IDE | `~/Library/Application Support/Kiro/` (macOS) |
Expand Down
1 change: 1 addition & 0 deletions cmd/agentsview/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,7 @@ func writeRootHelp(w io.Writer, root *cobra.Command) {
fmt.Fprintln(w, " IFLOW_DIR iFlow projects directory")
fmt.Fprintln(w, " AMP_DIR Amp threads directory")
fmt.Fprintln(w, " QWEN_PROJECTS_DIR Qwen Code projects directory")
fmt.Fprintln(w, " QCLAW_DIR QClaw agents directory")
fmt.Fprintln(w, " PIEBALD_DIR Piebald data directory")
fmt.Fprintln(w, " AGENTSVIEW_DATA_DIR Data directory (database, config)")
fmt.Fprintln(w, " AGENTSVIEW_PG_URL PostgreSQL connection URL for sync")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
pi: "Pi",
qwen: "Qwen Code",
openclaw: "OpenClaw",
qclaw: "QClaw",
kimi: "Kimi",
piebald: "Piebald",
antigravity: "Antigravity",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/lib/utils/agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe("KNOWN_AGENTS", () => {
"pi",
"qwen",
"openclaw",
"qclaw",
"iflow",
"kimi",
"claude-ai",
Expand Down Expand Up @@ -80,6 +81,9 @@ describe("agentColor", () => {
expect(agentColor("vscode-copilot")).toBe(
"var(--accent-teal)",
);
expect(agentColor("qclaw")).toBe(
"var(--accent-orange)",
);
expect(agentColor("piebald")).toBe(
"var(--accent-orange)",
);
Expand All @@ -100,6 +104,7 @@ describe("agentLabel", () => {
);
expect(agentLabel("openhands")).toBe("OpenHands");
expect(agentLabel("openclaw")).toBe("OpenClaw");
expect(agentLabel("qclaw")).toBe("QClaw");
expect(agentLabel("iflow")).toBe("iFlow");
expect(agentLabel("piebald")).toBe("Piebald");
expect(agentLabel("qwen")).toBe("Qwen Code");
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/lib/utils/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export const KNOWN_AGENTS: readonly AgentMeta[] = [
color: "var(--accent-orange)",
label: "OpenClaw",
},
{
name: "qclaw",
color: "var(--accent-orange)",
label: "QClaw",
},
{ name: "iflow", color: "var(--accent-sky)", label: "iFlow" },
{ name: "kimi", color: "var(--accent-pink)", label: "Kimi" },
{ name: "claude-ai", color: "var(--accent-violet)", label: "Claude.ai" },
Expand Down
204 changes: 204 additions & 0 deletions internal/parser/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -1797,6 +1797,210 @@ func FindOpenClawSourceFile(agentsDir, rawID string) string {
return ""
}

// DiscoverQClawSessions finds all JSONL session files under the
// QClaw agents directory. The directory structure is:
// <agentsDir>/<agentId>/sessions/<sessionId>.jsonl
//
// When both active (.jsonl) and archived (.jsonl.deleted.*,
// .jsonl.full.bak, .jsonl.reset.*) files exist for the same
// logical session ID, only one file is returned per session:
// the active .jsonl file is preferred; if absent, the newest
// archived file (by filename, which embeds a timestamp, or by
// file mtime as a fallback) is chosen.
func DiscoverQClawSessions(agentsDir string) []DiscoveredFile {
if agentsDir == "" {
return nil
}

// Each agent has its own subdirectory.
agentEntries, err := os.ReadDir(agentsDir)
if err != nil {
return nil
}

var files []DiscoveredFile
for _, agentEntry := range agentEntries {
if !isDirOrSymlink(agentEntry, agentsDir) {
continue
}
if !IsValidSessionID(agentEntry.Name()) {
continue
}

sessionsDir := filepath.Join(
agentsDir, agentEntry.Name(), "sessions",
)
entries, err := os.ReadDir(sessionsDir)
if err != nil {
continue
}

// Deduplicate by logical session ID within each
// agent's sessions directory.
best := make(map[string]os.DirEntry) // sessionID -> best entry
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !IsQClawSessionFile(name) {
continue
}
sid := QClawSessionID(name)
prev, exists := best[sid]
if !exists {
best[sid] = entry
continue
}
best[sid] = bestQClawEntry(prev, entry)
}

for _, entry := range best {
files = append(files, DiscoveredFile{
Path: filepath.Join(
sessionsDir, entry.Name(),
),
Agent: AgentQClaw,
})
}
}

sort.Slice(files, func(i, j int) bool {
return files[i].Path < files[j].Path
})
return files
}

// bestQClawEntry returns the preferred entry when two files
// share the same logical session ID. Active .jsonl files always
// win. Among archived files, the one with the newest embedded
// timestamp wins; when no timestamp is parseable, mtime is used.
func bestQClawEntry(a, b os.DirEntry) os.DirEntry {
aActive := strings.HasSuffix(a.Name(), ".jsonl")
bActive := strings.HasSuffix(b.Name(), ".jsonl")
if aActive && !bActive {
return a
}
if bActive && !aActive {
return b
}
aTime := qClawArchiveTime(a)
bTime := qClawArchiveTime(b)
if !aTime.IsZero() && !bTime.IsZero() {
if bTime.After(aTime) {
return b
}
return a
}
if !aTime.IsZero() {
return a
}
if !bTime.IsZero() {
return b
}
ai, errA := a.Info()
bi, errB := b.Info()
if errA == nil && errB == nil &&
bi.ModTime().After(ai.ModTime()) {
return b
}
return a
}

// qClawArchiveTime extracts the timestamp embedded in an
// QClaw archive filename suffix (e.g. ".deleted.2026-02-19T08-59-24.951Z").
func qClawArchiveTime(e os.DirEntry) time.Time {
name := e.Name()
idx := strings.Index(name, ".jsonl.")
if idx <= 0 {
return time.Time{}
}
suffix := name[idx+len(".jsonl."):]
// suffix is e.g. "deleted.2026-02-19T08-59-24.951Z" or "full.bak"
_, tsStr, ok := strings.Cut(suffix, ".")
if !ok {
return time.Time{}
}
// Convert dash-separated time back to colons: 08-59-24 → 08:59:24
if tIdx := strings.IndexByte(tsStr, 'T'); tIdx >= 0 {
datePart := tsStr[:tIdx+1]
timePart := tsStr[tIdx+1:]
// Only replace first two dashes in time portion (hh-mm-ss)
timePart = strings.Replace(timePart, "-", ":", 1)
timePart = strings.Replace(timePart, "-", ":", 1)
tsStr = datePart + timePart
}
t, err := time.Parse("2006-01-02T15:04:05.000Z", tsStr)
if err != nil {
t, err = time.Parse("2006-01-02T15:04:05Z", tsStr)
}
if err != nil {
return time.Time{}
}
return t
}

// FindQClawSourceFile locates a QClaw session file by its
// raw ID (without the "qclaw:" prefix). The raw ID has the
// format "<agentId>:<sessionId>", which directly maps to the
// file at <agentsDir>/<agentId>/sessions/<sessionId>.jsonl.
//
// If the active .jsonl file does not exist (archive-only session),
// the sessions directory is scanned for any archived file whose
// logical session ID matches. When multiple archived files match,
// the best candidate (newest by filename timestamp) is returned.
func FindQClawSourceFile(agentsDir, rawID string) string {
if agentsDir == "" {
return ""
}

// Split "agentId:sessionId" into its two parts.
agentID, sessionID, ok := strings.Cut(rawID, ":")
if !ok || !IsValidSessionID(agentID) ||
!IsValidSessionID(sessionID) {
return ""
}

sessionsDir := filepath.Join(
agentsDir, agentID, "sessions",
)

// Fast path: the active .jsonl file exists.
active := filepath.Join(sessionsDir, sessionID+".jsonl")
if _, err := os.Stat(active); err == nil {
return active
}

// Slow path: scan for archived files matching this session.
entries, err := os.ReadDir(sessionsDir)
if err != nil {
return ""
}

var best os.DirEntry
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !IsQClawSessionFile(name) {
continue
}
if QClawSessionID(name) != sessionID {
continue
}
if best == nil {
best = entry
continue
}
best = bestQClawEntry(best, entry)
}
if best != nil {
return filepath.Join(sessionsDir, best.Name())
}
return ""
}

// DiscoverIflowProjects finds all project directories under the
// iFlow projects dir and returns their JSONL session files.
// iFlow stores sessions in .iflow/projects/<project>/session-<uuid>.jsonl
Expand Down
Loading
Loading