Skip to content

Commit 496bfdf

Browse files
arbingwesm
authored andcommitted
feat: add QClaw agent support
- fix: isolate QClaw during e2e runs - fix: preserve QClaw tool result content
1 parent 4174459 commit 496bfdf

13 files changed

Lines changed: 1503 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ agentsview auto-discovers sessions from all of these:
181181
| Pi | `~/.pi/agent/sessions/` |
182182
| Qwen Code | `~/.qwen/projects/` |
183183
| OpenClaw | `~/.openclaw/agents/` |
184+
| QClaw | `~/.qclaw/agents/` |
184185
| Kimi | `~/.kimi/sessions/` |
185186
| Kiro CLI | `~/.kiro/sessions/cli/`, `~/.local/share/kiro-cli/` |
186187
| Kiro IDE | `~/Library/Application Support/Kiro/` (macOS) |

cmd/agentsview/cli.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,7 @@ func writeRootHelp(w io.Writer, root *cobra.Command) {
432432
fmt.Fprintln(w, " IFLOW_DIR iFlow projects directory")
433433
fmt.Fprintln(w, " AMP_DIR Amp threads directory")
434434
fmt.Fprintln(w, " QWEN_PROJECTS_DIR Qwen Code projects directory")
435+
fmt.Fprintln(w, " QCLAW_DIR QClaw agents directory")
435436
fmt.Fprintln(w, " PIEBALD_DIR Piebald data directory")
436437
fmt.Fprintln(w, " AGENTSVIEW_DATA_DIR Data directory (database, config)")
437438
fmt.Fprintln(w, " AGENTSVIEW_PG_URL PostgreSQL connection URL for sync")

frontend/src/lib/components/settings/AgentDirSettings.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
pi: "Pi",
1717
qwen: "Qwen Code",
1818
openclaw: "OpenClaw",
19+
qclaw: "QClaw",
1920
kimi: "Kimi",
2021
piebald: "Piebald",
2122
antigravity: "Antigravity",

frontend/src/lib/utils/agents.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ describe("KNOWN_AGENTS", () => {
2222
"pi",
2323
"qwen",
2424
"openclaw",
25+
"qclaw",
2526
"iflow",
2627
"kimi",
2728
"claude-ai",
@@ -80,6 +81,9 @@ describe("agentColor", () => {
8081
expect(agentColor("vscode-copilot")).toBe(
8182
"var(--accent-teal)",
8283
);
84+
expect(agentColor("qclaw")).toBe(
85+
"var(--accent-orange)",
86+
);
8387
expect(agentColor("piebald")).toBe(
8488
"var(--accent-orange)",
8589
);
@@ -100,6 +104,7 @@ describe("agentLabel", () => {
100104
);
101105
expect(agentLabel("openhands")).toBe("OpenHands");
102106
expect(agentLabel("openclaw")).toBe("OpenClaw");
107+
expect(agentLabel("qclaw")).toBe("QClaw");
103108
expect(agentLabel("iflow")).toBe("iFlow");
104109
expect(agentLabel("piebald")).toBe("Piebald");
105110
expect(agentLabel("qwen")).toBe("Qwen Code");

frontend/src/lib/utils/agents.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ export const KNOWN_AGENTS: readonly AgentMeta[] = [
2626
color: "var(--accent-orange)",
2727
label: "OpenClaw",
2828
},
29+
{
30+
name: "qclaw",
31+
color: "var(--accent-orange)",
32+
label: "QClaw",
33+
},
2934
{ name: "iflow", color: "var(--accent-sky)", label: "iFlow" },
3035
{ name: "kimi", color: "var(--accent-pink)", label: "Kimi" },
3136
{ name: "claude-ai", color: "var(--accent-violet)", label: "Claude.ai" },

internal/parser/discovery.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1797,6 +1797,210 @@ func FindOpenClawSourceFile(agentsDir, rawID string) string {
17971797
return ""
17981798
}
17991799

1800+
// DiscoverQClawSessions finds all JSONL session files under the
1801+
// QClaw agents directory. The directory structure is:
1802+
// <agentsDir>/<agentId>/sessions/<sessionId>.jsonl
1803+
//
1804+
// When both active (.jsonl) and archived (.jsonl.deleted.*,
1805+
// .jsonl.full.bak, .jsonl.reset.*) files exist for the same
1806+
// logical session ID, only one file is returned per session:
1807+
// the active .jsonl file is preferred; if absent, the newest
1808+
// archived file (by filename, which embeds a timestamp, or by
1809+
// file mtime as a fallback) is chosen.
1810+
func DiscoverQClawSessions(agentsDir string) []DiscoveredFile {
1811+
if agentsDir == "" {
1812+
return nil
1813+
}
1814+
1815+
// Each agent has its own subdirectory.
1816+
agentEntries, err := os.ReadDir(agentsDir)
1817+
if err != nil {
1818+
return nil
1819+
}
1820+
1821+
var files []DiscoveredFile
1822+
for _, agentEntry := range agentEntries {
1823+
if !isDirOrSymlink(agentEntry, agentsDir) {
1824+
continue
1825+
}
1826+
if !IsValidSessionID(agentEntry.Name()) {
1827+
continue
1828+
}
1829+
1830+
sessionsDir := filepath.Join(
1831+
agentsDir, agentEntry.Name(), "sessions",
1832+
)
1833+
entries, err := os.ReadDir(sessionsDir)
1834+
if err != nil {
1835+
continue
1836+
}
1837+
1838+
// Deduplicate by logical session ID within each
1839+
// agent's sessions directory.
1840+
best := make(map[string]os.DirEntry) // sessionID -> best entry
1841+
for _, entry := range entries {
1842+
if entry.IsDir() {
1843+
continue
1844+
}
1845+
name := entry.Name()
1846+
if !IsQClawSessionFile(name) {
1847+
continue
1848+
}
1849+
sid := QClawSessionID(name)
1850+
prev, exists := best[sid]
1851+
if !exists {
1852+
best[sid] = entry
1853+
continue
1854+
}
1855+
best[sid] = bestQClawEntry(prev, entry)
1856+
}
1857+
1858+
for _, entry := range best {
1859+
files = append(files, DiscoveredFile{
1860+
Path: filepath.Join(
1861+
sessionsDir, entry.Name(),
1862+
),
1863+
Agent: AgentQClaw,
1864+
})
1865+
}
1866+
}
1867+
1868+
sort.Slice(files, func(i, j int) bool {
1869+
return files[i].Path < files[j].Path
1870+
})
1871+
return files
1872+
}
1873+
1874+
// bestQClawEntry returns the preferred entry when two files
1875+
// share the same logical session ID. Active .jsonl files always
1876+
// win. Among archived files, the one with the newest embedded
1877+
// timestamp wins; when no timestamp is parseable, mtime is used.
1878+
func bestQClawEntry(a, b os.DirEntry) os.DirEntry {
1879+
aActive := strings.HasSuffix(a.Name(), ".jsonl")
1880+
bActive := strings.HasSuffix(b.Name(), ".jsonl")
1881+
if aActive && !bActive {
1882+
return a
1883+
}
1884+
if bActive && !aActive {
1885+
return b
1886+
}
1887+
aTime := qClawArchiveTime(a)
1888+
bTime := qClawArchiveTime(b)
1889+
if !aTime.IsZero() && !bTime.IsZero() {
1890+
if bTime.After(aTime) {
1891+
return b
1892+
}
1893+
return a
1894+
}
1895+
if !aTime.IsZero() {
1896+
return a
1897+
}
1898+
if !bTime.IsZero() {
1899+
return b
1900+
}
1901+
ai, errA := a.Info()
1902+
bi, errB := b.Info()
1903+
if errA == nil && errB == nil &&
1904+
bi.ModTime().After(ai.ModTime()) {
1905+
return b
1906+
}
1907+
return a
1908+
}
1909+
1910+
// qClawArchiveTime extracts the timestamp embedded in an
1911+
// QClaw archive filename suffix (e.g. ".deleted.2026-02-19T08-59-24.951Z").
1912+
func qClawArchiveTime(e os.DirEntry) time.Time {
1913+
name := e.Name()
1914+
idx := strings.Index(name, ".jsonl.")
1915+
if idx <= 0 {
1916+
return time.Time{}
1917+
}
1918+
suffix := name[idx+len(".jsonl."):]
1919+
// suffix is e.g. "deleted.2026-02-19T08-59-24.951Z" or "full.bak"
1920+
_, tsStr, ok := strings.Cut(suffix, ".")
1921+
if !ok {
1922+
return time.Time{}
1923+
}
1924+
// Convert dash-separated time back to colons: 08-59-24 → 08:59:24
1925+
if tIdx := strings.IndexByte(tsStr, 'T'); tIdx >= 0 {
1926+
datePart := tsStr[:tIdx+1]
1927+
timePart := tsStr[tIdx+1:]
1928+
// Only replace first two dashes in time portion (hh-mm-ss)
1929+
timePart = strings.Replace(timePart, "-", ":", 1)
1930+
timePart = strings.Replace(timePart, "-", ":", 1)
1931+
tsStr = datePart + timePart
1932+
}
1933+
t, err := time.Parse("2006-01-02T15:04:05.000Z", tsStr)
1934+
if err != nil {
1935+
t, err = time.Parse("2006-01-02T15:04:05Z", tsStr)
1936+
}
1937+
if err != nil {
1938+
return time.Time{}
1939+
}
1940+
return t
1941+
}
1942+
1943+
// FindQClawSourceFile locates a QClaw session file by its
1944+
// raw ID (without the "qclaw:" prefix). The raw ID has the
1945+
// format "<agentId>:<sessionId>", which directly maps to the
1946+
// file at <agentsDir>/<agentId>/sessions/<sessionId>.jsonl.
1947+
//
1948+
// If the active .jsonl file does not exist (archive-only session),
1949+
// the sessions directory is scanned for any archived file whose
1950+
// logical session ID matches. When multiple archived files match,
1951+
// the best candidate (newest by filename timestamp) is returned.
1952+
func FindQClawSourceFile(agentsDir, rawID string) string {
1953+
if agentsDir == "" {
1954+
return ""
1955+
}
1956+
1957+
// Split "agentId:sessionId" into its two parts.
1958+
agentID, sessionID, ok := strings.Cut(rawID, ":")
1959+
if !ok || !IsValidSessionID(agentID) ||
1960+
!IsValidSessionID(sessionID) {
1961+
return ""
1962+
}
1963+
1964+
sessionsDir := filepath.Join(
1965+
agentsDir, agentID, "sessions",
1966+
)
1967+
1968+
// Fast path: the active .jsonl file exists.
1969+
active := filepath.Join(sessionsDir, sessionID+".jsonl")
1970+
if _, err := os.Stat(active); err == nil {
1971+
return active
1972+
}
1973+
1974+
// Slow path: scan for archived files matching this session.
1975+
entries, err := os.ReadDir(sessionsDir)
1976+
if err != nil {
1977+
return ""
1978+
}
1979+
1980+
var best os.DirEntry
1981+
for _, entry := range entries {
1982+
if entry.IsDir() {
1983+
continue
1984+
}
1985+
name := entry.Name()
1986+
if !IsQClawSessionFile(name) {
1987+
continue
1988+
}
1989+
if QClawSessionID(name) != sessionID {
1990+
continue
1991+
}
1992+
if best == nil {
1993+
best = entry
1994+
continue
1995+
}
1996+
best = bestQClawEntry(best, entry)
1997+
}
1998+
if best != nil {
1999+
return filepath.Join(sessionsDir, best.Name())
2000+
}
2001+
return ""
2002+
}
2003+
18002004
// DiscoverIflowProjects finds all project directories under the
18012005
// iFlow projects dir and returns their JSONL session files.
18022006
// iFlow stores sessions in .iflow/projects/<project>/session-<uuid>.jsonl

0 commit comments

Comments
 (0)