Skip to content

Commit 0fedc67

Browse files
clkaoclaudewesm
authored
feat: session DAG with fork detection and subagent linking (#43)
Closes #17 ## Summary - Detect conversation forks in Claude Code sessions by building a uuid/parentUuid DAG, splitting large-gap branches into separate session records - Link Task tool calls to their spawned subagent sessions via queue-operation parsing (JSON and XML formats) - Add inline subagent conversation expansion in the frontend ## End-User Impact - **Fork detection**: Conversation branches (e.g., from Claude Code's "retry from here") are detected and split into separate session entries instead of being jumbled together. Small retries (≤3 user turns) fold into the main session; larger forks appear as standalone sessions. - **Subagent session linking**: Task subagent conversations are linked to the specific tool call that launched them. Task tool blocks show an expandable inline view of the subagent's conversation. - **Cleaner session list**: Subagent and fork sessions are hidden from the top-level list — accessible only through their parent session context. ## Design **DAG from uuid/parentUuid:** Every Claude Code JSONL entry carries `uuid` and `parentUuid` fields forming a tree. The parser builds a parent→children adjacency map in a single pass, then walks from root following first-child links. At fork points (nodes with multiple children), a heuristic counts user turns remaining on the first branch: >3 turns = real fork (split into separate `ParseResult`), ≤3 turns = retry (follow latest child, discard older branch). **Subagent linking from queue-operation:** Claude Code writes `queue-operation` entries with `operation: "enqueue"` when spawning subagents. The `content` field maps `tool_use_id` → `task_id`. Two formats exist in the wild: JSON (`{"task_id":"...","tool_use_id":"..."}`) and XML (`<task-id>...</task-id><tool-use-id>...</tool-use-id>`). Parser tries JSON first via `gjson.Get`, falls back to regex for XML tags. **Data model — relationship_type:** Sessions gain a `relationship_type` column (`""`, `"continuation"`, `"subagent"`, `"fork"`). Fork sessions get ID `{parent}-{first-uuid}` with `parent_session_id` pointing to main session. Tool calls gain `subagent_session_id` linking to the agent session. **API — child sessions endpoint:** `GET /api/v1/sessions/{id}/children` returns fork/continuation/subagent sessions for a parent. ## Test plan - [ ] Verify fork detection unit tests pass (`go test ./internal/parser/ -run TestForkDetection`) - [ ] Verify subagent linking tests pass (`go test ./internal/parser/ -run TestSubagent`) - [ ] Verify integration tests pass (`go test ./internal/sync/ -run TestSync`) - [ ] Manual: load a session with Task subagents and verify inline expansion works - [ ] Manual: verify subagent/fork sessions don't appear in top-level session list 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Wes McKinney <wesmckinn+git@gmail.com>
1 parent 11e4145 commit 0fedc67

28 files changed

+2398
-167
lines changed

.roborev.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,22 @@ Key assumptions reviewers MUST account for:
4444
launching agentsview. Do not flag env var inheritance to agent
4545
CLIs as a security issue.
4646
47+
9. SESSION FILE PARSING: Session files (JSONL, JSON) are produced by
48+
agent CLIs running on the user's own machine. The parser does not
49+
need to defend against adversarial or malicious input. Do not flag:
50+
- Missing cycle detection in DAG/tree traversals (UUIDs are unique)
51+
- Missing recursion depth limits (fork depth is bounded by human
52+
interaction — each fork requires a user backtracking)
53+
- Unreachable-node checks in DAG connectivity (degenerate structures
54+
already fall back to linear parsing via multi-root and dangling
55+
parentUuid checks)
56+
57+
10. WRITE ATOMICITY: Session data is written per-session in individual
58+
transactions. Cross-session atomicity (e.g. writing all fork
59+
sessions from one file in a single transaction) is not required.
60+
A full resync recovers any partial state. Do not flag non-atomic
61+
multi-session writes as a data corruption risk.
62+
4763
Do NOT flag issues that only apply to public-facing, multi-tenant,
4864
or network-exposed services. Focus on bugs, logic errors, data
4965
corruption risks, and code quality issues.

frontend/src/lib/api/types/core.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface Session {
1717
message_count: number;
1818
user_message_count: number;
1919
parent_session_id?: string;
20+
relationship_type?: string;
2021
file_path?: string;
2122
file_size?: number;
2223
file_mtime?: number;
@@ -44,6 +45,7 @@ export interface ToolCall {
4445
input_json?: string;
4546
skill_name?: string;
4647
result_content_length?: number;
48+
subagent_session_id?: string;
4749
}
4850

4951
/** Matches Go Message struct in internal/db/messages.go */
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<!-- ABOUTME: Expandable inline view of a subagent's conversation.
2+
ABOUTME: Lazily loads and renders subagent messages within a parent ToolBlock. -->
3+
<script lang="ts">
4+
import type { Message } from "../../api/types.js";
5+
import { getMessages } from "../../api/client.js";
6+
import MessageContent from "./MessageContent.svelte";
7+
8+
interface Props {
9+
sessionId: string;
10+
}
11+
12+
let { sessionId }: Props = $props();
13+
let expanded = $state(false);
14+
let messages: Message[] | null = $state(null);
15+
let loading = $state(false);
16+
let error: string | null = $state(null);
17+
18+
async function toggleExpand() {
19+
expanded = !expanded;
20+
if (expanded && !messages) {
21+
loading = true;
22+
error = null;
23+
try {
24+
const resp = await getMessages(sessionId, { limit: 1000 });
25+
messages = resp.messages;
26+
} catch (e) {
27+
error = e instanceof Error ? e.message : "Failed to load";
28+
} finally {
29+
loading = false;
30+
}
31+
}
32+
}
33+
</script>
34+
35+
<div class="subagent-inline">
36+
<button class="subagent-toggle" onclick={toggleExpand}>
37+
<span class="toggle-chevron" class:open={expanded}>&#9656;</span>
38+
<span class="toggle-label">Subagent session</span>
39+
<span class="toggle-session-id">{sessionId}</span>
40+
</button>
41+
42+
{#if expanded}
43+
<div class="subagent-messages">
44+
{#if loading}
45+
<div class="subagent-status">Loading...</div>
46+
{:else if error}
47+
<div class="subagent-status subagent-error">{error}</div>
48+
{:else if messages && messages.length > 0}
49+
{#each messages as message}
50+
<MessageContent {message} />
51+
{/each}
52+
{:else if messages}
53+
<div class="subagent-status">No messages</div>
54+
{/if}
55+
</div>
56+
{/if}
57+
</div>
58+
59+
<style>
60+
.subagent-inline {
61+
border-top: 1px solid var(--border-muted);
62+
margin-top: 2px;
63+
}
64+
65+
.subagent-toggle {
66+
display: flex;
67+
align-items: center;
68+
gap: 6px;
69+
padding: 6px 10px;
70+
width: 100%;
71+
text-align: left;
72+
font-size: 11px;
73+
color: var(--accent-green);
74+
border-radius: 0 0 var(--radius-sm) 0;
75+
transition: background 0.1s;
76+
}
77+
78+
.subagent-toggle:hover {
79+
background: var(--bg-surface-hover);
80+
}
81+
82+
.toggle-chevron {
83+
display: inline-block;
84+
font-size: 10px;
85+
transition: transform 0.15s;
86+
flex-shrink: 0;
87+
}
88+
89+
.toggle-chevron.open {
90+
transform: rotate(90deg);
91+
}
92+
93+
.toggle-label {
94+
font-weight: 600;
95+
white-space: nowrap;
96+
}
97+
98+
.toggle-session-id {
99+
font-family: var(--font-mono);
100+
font-size: 10px;
101+
color: var(--text-muted);
102+
overflow: hidden;
103+
text-overflow: ellipsis;
104+
white-space: nowrap;
105+
min-width: 0;
106+
}
107+
108+
.subagent-messages {
109+
border-left: 3px solid var(--accent-green);
110+
margin: 0 0 4px 10px;
111+
display: flex;
112+
flex-direction: column;
113+
gap: 4px;
114+
padding: 4px 0;
115+
}
116+
117+
.subagent-status {
118+
padding: 8px 14px;
119+
font-size: 12px;
120+
color: var(--text-muted);
121+
}
122+
123+
.subagent-error {
124+
color: var(--accent-red);
125+
}
126+
</style>

frontend/src/lib/components/content/ToolBlock.svelte

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
<!-- ABOUTME: Renders a collapsible tool call block with metadata tags and content. -->
2+
<!-- ABOUTME: Supports Task tool calls with inline subagent conversation expansion. -->
13
<script lang="ts">
24
import type { ToolCall } from "../../api/types.js";
5+
import SubagentInline from "./SubagentInline.svelte";
36
47
interface Props {
58
content: string;
@@ -85,6 +88,12 @@
8588
? inputParams?.prompt ?? null
8689
: null,
8790
);
91+
92+
let subagentSessionId = $derived(
93+
toolCall?.tool_name === "Task"
94+
? toolCall?.subagent_session_id ?? null
95+
: null,
96+
);
8897
</script>
8998

9099
<div class="tool-block">
@@ -119,6 +128,9 @@
119128
<pre class="tool-content">{content}</pre>
120129
{/if}
121130
{/if}
131+
{#if subagentSessionId}
132+
<SubagentInline sessionId={subagentSessionId} />
133+
{/if}
122134
</div>
123135

124136
<style>

frontend/src/lib/components/sidebar/SessionList.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@
130130

131131
<div class="session-list-header">
132132
<span class="session-count">
133-
{formatNumber(sessions.total)} sessions
133+
{formatNumber(totalCount)} sessions
134134
</span>
135135
<div class="header-actions">
136136
{#if sessions.loading}

frontend/src/lib/stores/sessions.svelte.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,7 @@ function findRoot(
390390
visited.add(cur);
391391
const s = byId.get(cur);
392392
if (!s?.parent_session_id) break;
393+
if (s.relationship_type === "fork") break;
393394
const parent = s.parent_session_id;
394395
if (!byId.has(parent)) break; // missing link
395396
cur = parent;
@@ -415,6 +416,9 @@ export function buildSessionGroups(
415416
const insertionOrder: string[] = [];
416417

417418
for (const s of sessions) {
419+
// Subagent sessions are only visible through their parent.
420+
if (s.relationship_type === "subagent") continue;
421+
418422
const root = findRoot(s.id, byId, rootCache);
419423
// Sessions without a parent_session_id that aren't
420424
// pointed to by anyone get root == their own id, so

internal/db/db.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,35 @@ func needsRebuild(path string) (bool, error) {
175175
"probing schema: %w", err,
176176
)
177177
}
178-
return umcCount == 0, nil
178+
if umcCount == 0 {
179+
return true, nil
180+
}
181+
182+
var relTypeCount int
183+
err = conn.QueryRow(
184+
`SELECT count(*) FROM pragma_table_info('sessions')
185+
WHERE name = 'relationship_type'`,
186+
).Scan(&relTypeCount)
187+
if err != nil {
188+
return false, fmt.Errorf(
189+
"probing schema: %w", err,
190+
)
191+
}
192+
if relTypeCount == 0 {
193+
return true, nil
194+
}
195+
196+
var subagentColCount int
197+
err = conn.QueryRow(
198+
`SELECT count(*) FROM pragma_table_info('tool_calls')
199+
WHERE name = 'subagent_session_id'`,
200+
).Scan(&subagentColCount)
201+
if err != nil {
202+
return false, fmt.Errorf(
203+
"probing schema: %w", err,
204+
)
205+
}
206+
return subagentColCount == 0, nil
179207
}
180208

181209
func dropDatabase(path string) error {

0 commit comments

Comments
 (0)