@gswangg/duncan-cc 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,71 +30,206 @@ claude mcp add duncan -- npx tsx /path/to/duncan-cc/src/mcp-server.ts
30
30
 
31
31
  ## Authentication
32
32
 
33
- Duncan resolves auth automatically:
33
+ Duncan resolves auth automatically in this order:
34
34
 
35
- 1. Explicit apiKey/token parameter
36
- 2. CC OAuth credentials (`~/.claude/.credentials.json`)
37
- 3. `ANTHROPIC_API_KEY` environment variable
35
+ 1. **Explicit** apiKey/token parameter (if passed)
36
+ 2. **CC OAuth** credentials from `~/.claude/.credentials.json` (primary for CC users)
37
+ 3. **API key** from `ANTHROPIC_API_KEY` environment variable
38
+
39
+ Most CC users authenticate via OAuth — duncan picks this up automatically with no configuration.
38
40
 
39
41
  ## Tools
40
42
 
43
+ Duncan exposes three MCP tools:
44
+
41
45
  ### `duncan_query`
42
46
 
43
- | Parameter | Type | Required | Description |
44
- |-----------|------|----------|-------------|
45
- | `question` | string | | The question to ask |
46
- | `mode` | string | ✓ | `project`, `global`, `session`, `self`, `ancestors` |
47
- | `projectDir` | string | | For project mode |
48
- | `sessionId` | string | | For session mode |
49
- | `cwd` | string | | Working directory for context resolution |
50
- | `limit` | number | | Max sessions/windows (default: 10) |
51
- | `offset` | number | | Pagination offset |
52
- | `copies` | number | | For self mode: sample count (default: 3) |
53
- | `includeSubagents` | boolean | | Include subagent transcripts |
47
+ Query dormant sessions to recall information from previous conversations. Loads session context and asks the target session's model whether it has relevant information.
48
+
49
+ | Parameter | Type | Required | Default | Description |
50
+ |-----------|------|----------|---------|-------------|
51
+ | `question` | string | | | The question to ask. Be specific and self-contained. |
52
+ | `mode` | string | | | Routing mode (see [Routing Modes](#routing-modes) below) |
53
+ | `projectDir` | string | | cwd-based | Explicit project directory path (for `project` and `branch` modes) |
54
+ | `sessionId` | string | | | Session file path or ID (for `session` mode) |
55
+ | `cwd` | string | | process.cwd() | Working directory for context resolution |
56
+ | `limit` | number | | 10 | Max sessions/windows to query |
57
+ | `offset` | number | | 0 | Skip this many sessions for pagination |
58
+ | `copies` | number | | 3 | For `self` mode: number of parallel samples |
59
+ | `includeSubagents` | boolean | | false | Include subagent transcripts in search |
60
+ | `batchSize` | number | | 5 | Max concurrent API calls per batch |
61
+ | `gitBranch` | string | | auto-detected | For `branch` mode: explicit branch name |
62
+
63
+ ### `duncan_projects`
64
+
65
+ List all CC projects with metadata. Use to discover what projects exist before targeting a specific project with `duncan_query`.
66
+
67
+ | Parameter | Type | Required | Default | Description |
68
+ |-----------|------|----------|---------|-------------|
69
+ | `limit` | number | | 50 | Max projects to list |
70
+ | `offset` | number | | 0 | Pagination offset |
71
+
72
+ Returns for each project:
73
+ - Original working directory path (reconstructed from CC's hashed directory name)
74
+ - Session count
75
+ - Most recent activity timestamp
76
+ - Git branches seen across recent sessions
54
77
 
55
78
  ### `duncan_list_sessions`
56
79
 
57
- | Parameter | Type | Required | Description |
58
- |-----------|------|----------|-------------|
59
- | `mode` | string | | `project` or `global` |
60
- | `projectDir` | string | | For project mode |
61
- | `cwd` | string | | Working directory |
62
- | `limit` | number | | Max sessions (default: 20) |
80
+ List available sessions for a project or globally.
81
+
82
+ | Parameter | Type | Required | Default | Description |
83
+ |-----------|------|----------|---------|-------------|
84
+ | `mode` | string | | | `project` or `global` |
85
+ | `projectDir` | string | | cwd-based | For `project` mode |
86
+ | `cwd` | string | | process.cwd() | Working directory |
87
+ | `limit` | number | | 20 | Max sessions to list |
88
+
89
+ ## Routing Modes
90
+
91
+ ### `project`
92
+
93
+ Query all sessions from the same project directory. The calling session is automatically excluded via self-detection. Sessions are ordered by modification time (newest first).
94
+
95
+ Use when you want to search through recent work in the current project.
96
+
97
+ ### `global`
98
+
99
+ Query all sessions across all CC projects (newest first). Self-excluded. The broadest search — useful when you don't know which project holds the information.
100
+
101
+ ### `session`
102
+
103
+ Query a specific session by ID or file path. No self-exclusion (you might intentionally target a known session). Pass the session ID via the `sessionId` parameter — either a UUID or full file path.
104
+
105
+ ### `self`
63
106
 
64
- ## Routing modes
107
+ Query your own active window multiple times for **sampling diversity**. Instead of searching other sessions, this sends the same question to N independent copies of the current conversation context. Useful for exploring different perspectives on complex problems.
65
108
 
66
- | Mode | Target |
67
- |------|--------|
68
- | `project` | Sessions from same project dir (self-excluded) |
69
- | `global` | All sessions across all projects (self-excluded) |
70
- | `session` | Specific session by ID or path |
71
- | `self` | Own active window, queried N times for sampling diversity |
72
- | `ancestors` | Own prior compaction windows (excluding active) |
109
+ Uses a **two-wave cache strategy**:
110
+ 1. **Wave 1**: 1 query primes the prompt cache (pays full input token cost)
111
+ 2. **Wave 2**: remaining N-1 queries in parallel (hit cached prefix, ~90% cheaper)
73
112
 
74
- ## How it works
113
+ The `copies` parameter controls how many samples to take (default: 3).
75
114
 
76
- Duncan replicates CC's full session-to-API pipeline, then substitutes its own query:
115
+ ### `ancestors`
77
116
 
78
- 1. Parse JSONL session file
79
- 2. Relink preserved segments (compaction tree surgery)
80
- 3. Walk parentUuid chain from leaf to root
81
- 4. Post-process (merge split assistants, fix orphan tool results)
82
- 5. Normalize messages (filter, convert types, merge, 8 post-transforms)
83
- 6. Apply content replacements (persisted outputs from disk)
84
- 7. Microcompact (truncate old tool results)
85
- 8. Inject userContext (CLAUDE.md + date)
86
- 9. Build system prompt (full parity with CC's static sections + dynamic context from project dir)
87
- 10. Convert to API format
88
- 11. Add prompt caching breakpoints
89
- 12. Query with `duncan_response` structured output tool
117
+ Query the calling session's **prior compaction windows**. When CC compacts a session, the old context is summarized but the original messages remain in the JSONL file. Ancestors mode lets you query that pre-compaction context.
90
118
 
91
- Self-exclusion: the calling session is identified by scanning for the MCP `toolUseId` in session file tails deterministic, zero config, swarm-safe.
119
+ Returns nothing if the session has no compaction boundaries. In CC (which has no dfork lineage), "ancestors" always means the session's own compacted-away history.
120
+
121
+ ### `subagents`
122
+
123
+ Query **subagent transcripts** of the calling session. When CC spawns subagent tasks (e.g., parallel tool calls), their transcripts are stored alongside the main session. This mode searches through those transcripts.
124
+
125
+ Subagent files are sorted by modification time for deterministic pagination.
126
+
127
+ ### `branch`
128
+
129
+ Query all sessions in the same project that share the **same git branch** as the calling session. CC records `gitBranch` in JSONL entries, so this grouping works without explicit lineage metadata.
130
+
131
+ Useful when work on a feature spans multiple sessions — `branch` naturally groups them by the git branch they were on.
132
+
133
+ If the calling session's branch can't be auto-detected, pass it explicitly via the `gitBranch` parameter.
134
+
135
+ ## How It Works
136
+
137
+ ### Pipeline
138
+
139
+ Duncan replicates CC's full session-to-API message pipeline, then substitutes its own query as the final message:
140
+
141
+ ```
142
+ Session file (.jsonl)
143
+
144
+ ▼ Parse JSONL — separate transcript from metadata
145
+ ▼ Relink preserved segments (compaction tree surgery)
146
+ ▼ Walk parentUuid chain from leaf to root
147
+ ▼ Post-process (merge split assistants, fix orphan tool results)
148
+ ▼ Slice from last compaction boundary
149
+ ▼ Normalize messages (filter, convert types, merge, 8 post-transforms)
150
+ ▼ Content replacements (resolve persisted tool outputs from disk)
151
+ ▼ Microcompact (truncate old tool results)
152
+ ▼ Inject userContext (CLAUDE.md + date)
153
+ ▼ Build system prompt (full CC parity)
154
+ ▼ Convert to API format
155
+ ▼ Add prompt cache breakpoints
156
+ ▼ Append duncan question as final user message
157
+ ▼ Query with duncan_response structured output tool
158
+ ```
159
+
160
+ ### Self-Exclusion
161
+
162
+ When CC calls an MCP tool, it writes the assistant message (containing the `tool_use` block) to the session JSONL *before* invoking the tool. CC passes the tool_use ID in the MCP request's `_meta` as `claudecode/toolUseId`.
163
+
164
+ Duncan scans the last 32KB of candidate session files for this ID to deterministically identify the calling session — no configuration needed, safe for concurrent sessions.
165
+
166
+ ### System Prompt Reconstruction
167
+
168
+ Duncan rebuilds the system prompt with full parity to CC's own prompt:
169
+
170
+ **Static sections** (embedded verbatim from CC):
171
+ - Identity/intro, system rules, coding instructions
172
+ - Careful actions guidelines, tool usage, tone/style, output efficiency
173
+
174
+ **Dynamic sections** (reconstructed from session context):
175
+ - **Environment**: from session JSONL metadata (cwd, model) + local filesystem
176
+ - **CLAUDE.md**: loaded from session's original cwd hierarchy (if paths exist)
177
+ - **Memory**: from CC project directory (`~/.claude/projects/<hash>/memory/MEMORY.md`)
178
+ - **Tool-conditional instructions**: only included when the corresponding tools appear in the session (e.g., "use Read instead of cat" only when Read tool was used)
179
+ - **Language**: configurable
180
+
181
+ Tool schemas are NOT included — duncan sends only its own `duncan_response` tool. The session's original tools aren't callable during a duncan query.
182
+
183
+ ### Prompt Caching
184
+
185
+ Cache breakpoints placed on:
186
+ - **System prompt**: each text block gets `cache_control: { type: "ephemeral" }`
187
+ - **Messages**: breakpoint on last content block of penultimate message
188
+
189
+ This caches the session context (stable across queries) while letting the duncan question (last message) vary without invalidating cache. For multi-session batch queries, each session's context is cached independently.
190
+
191
+ ### Query Logging
192
+
193
+ Every query is logged to `~/.claude/duncan.jsonl` as append-only JSONL. Each record captures:
194
+
195
+ - Batch ID, question, answer, hasContext flag
196
+ - Target session, window index, source session
197
+ - Routing strategy, model used
198
+ - Token counts (input, output, cache creation, cache read)
199
+ - Latency in milliseconds, timestamp
200
+
201
+ Process with standard tools: `cat ~/.claude/duncan.jsonl | jq .`
202
+
203
+ ### Progress Notifications
204
+
205
+ During batch queries, duncan sends MCP progress notifications via the standard `progressToken` mechanism. As each session window completes, a notification is sent so the calling session can display real-time status (e.g., "Querying 3/7 sessions...").
206
+
207
+ ## Session Storage Layout
208
+
209
+ Duncan reads CC's native session storage:
210
+
211
+ ```
212
+ ~/.claude/
213
+ ├── .credentials.json # OAuth credentials
214
+ ├── duncan.jsonl # Query log (written by duncan)
215
+ └── projects/
216
+ └── <hashed-cwd>/ # e.g., -Users-foo-bar
217
+ ├── <session-id>.jsonl # Session transcript
218
+ ├── <session-id>/
219
+ │ ├── subagents/ # Subagent transcripts
220
+ │ │ └── <subdir>/
221
+ │ │ └── agent-<id>.jsonl
222
+ │ └── tool-results/ # Persisted tool outputs
223
+ │ └── <id>.txt
224
+ └── memory/
225
+ └── MEMORY.md # Project memory
226
+ ```
92
227
 
93
- ## Known gaps
228
+ ## Known Gaps
94
229
 
95
- - **MCP server instructions** — not available for dormant sessions (fetched live, not persisted)
96
- - **Tool schemas** — only `duncan_response` is sent; session's original tools aren't callable
97
- - **Compaction test coverage** — synthetic tests only; no real compacted sessions in test corpus
230
+ - **MCP server instructions** — CC injects MCP server `instructions` from the initialize handshake into the system prompt. These aren't persisted to disk, so duncan can't reconstruct them for dormant sessions. Equivalent to resuming a CC session with tools disconnected.
231
+ - **Tool schemas** — only `duncan_response` is sent; the session's original tools aren't callable during a duncan query.
232
+ - **Compaction test coverage** — compaction logic is tested with synthetic fixtures only. No real compacted sessions in the current test corpus (CC's 30-day `cleanupPeriodDays` default purged them before capture).
98
233
 
99
234
  ## Tests
100
235
 
@@ -106,5 +241,5 @@ Corpus-dependent tests skip gracefully when `testdata/` is absent.
106
241
 
107
242
  ## Related
108
243
 
109
- - [duncan-pi](https://github.com/gswangg/duncan-pi) — duncan for the [pi](https://github.com/badlogic/pi-mono) coding agent
244
+ - [duncan-pi](https://github.com/gswangg/duncan-pi) — Duncan for the [pi](https://github.com/badlogic/pi-mono) coding agent
110
245
  - [The Duncan Idaho Approach to Agent Memory](https://gswangg.net/posts/duncan-idaho-agent-memory) — design writeup
package/SPEC.md CHANGED
@@ -4,7 +4,8 @@
4
4
 
5
5
  Duncan-cc replicates CC's full message pipeline to hydrate dormant CC sessions,
6
6
  then queries them with questions via the Anthropic API. Exposed as an MCP server
7
- (stdio transport) with two tools: `duncan_query` and `duncan_list_sessions`.
7
+ (stdio transport) with three tools: `duncan_query`, `duncan_projects`, and
8
+ `duncan_list_sessions`.
8
9
 
9
10
  ## Pipeline: Disk → API
10
11
 
@@ -63,7 +64,7 @@ Build system prompt (full parity with CC):
63
64
  ├── System rules
64
65
  ├── Coding instructions
65
66
  ├── Careful actions guidelines
66
- ├── Tool usage
67
+ ├── Tool usage (+ per-tool instructions based on session's tools)
67
68
  ├── Tone and style
68
69
  ├── Output efficiency
69
70
  ├── Environment info (cwd, platform, model)
@@ -95,6 +96,8 @@ messages.create() with duncan_response tool
95
96
  | `session` | Specific session by ID/path | — |
96
97
  | `self` | Own active window, N copies (sampling diversity) | — (queries self intentionally) |
97
98
  | `ancestors` | Own prior compaction windows (excluding active) | Active window excluded |
99
+ | `subagents` | Subagent transcripts of the active session | — |
100
+ | `branch` | Sessions sharing the same git branch in the project | ✅ via toolUseId |
98
101
 
99
102
  ### Self-exclusion
100
103
 
@@ -116,6 +119,13 @@ Queries compaction windows of the calling session excluding the active window.
116
119
  Returns nothing if the session has no compaction boundaries. In CC (no dfork
117
120
  lineage), "ancestors" = the compacted-away context from the current session.
118
121
 
122
+ ### Branch mode
123
+
124
+ Collects all sessions from the same project directory that share a git branch
125
+ with the calling session, ordered by mtime. CC sessions store `gitBranch` in
126
+ their JSONL entries. If the calling session's branch can't be auto-detected,
127
+ it can be passed explicitly via the `gitBranch` parameter.
128
+
119
129
  ## Authentication
120
130
 
121
131
  Resolution order:
@@ -151,6 +161,24 @@ This matches CC's own resume behavior: rebuild system prompt from current state.
151
161
  Note: tool schemas are NOT included — duncan sends only its own `duncan_response`
152
162
  tool. The session's original tools are not callable during a duncan query.
153
163
 
164
+ ## Query Logging
165
+
166
+ Every query is logged to `~/.claude/duncan.jsonl` as append-only JSONL. Each record:
167
+ - `batchId`, `question`, `answer`, `hasContext`
168
+ - `targetSession`, `windowIndex`, `sourceSession`
169
+ - `strategy`, `model`
170
+ - `inputTokens`, `outputTokens`, `cacheCreationInputTokens`, `cacheReadInputTokens`
171
+ - `latencyMs`, `timestamp`
172
+
173
+ Logging is best-effort (failures don't break queries).
174
+
175
+ ## MCP Progress Notifications
176
+
177
+ During batch queries, progress notifications are sent via MCP's standard
178
+ `progressToken` mechanism. Each completed session window sends a notification
179
+ with `{ progress: completed, total: totalWindows }`. Requires the caller to
180
+ include `_meta.progressToken` in the request.
181
+
154
182
  ## Known Gaps
155
183
 
156
184
  ### MCP Server Instructions
@@ -172,22 +200,32 @@ Compaction logic is tested with synthetic fixtures only.
172
200
  - **Subagent transcripts**: `<project-dir>/<session-id>/subagents/<subdir>/agent-<id>.jsonl`
173
201
  - **Tool results**: `<project-dir>/<session-id>/tool-results/<id>.txt`
174
202
  - **Memory**: `<project-dir>/memory/MEMORY.md`
203
+ - **Query log**: `~/.claude/duncan.jsonl`
175
204
 
176
205
  ## MCP Server
177
206
 
178
- Two tools exposed via stdio transport:
207
+ Three tools exposed via stdio transport:
179
208
 
180
209
  ### duncan_query
181
210
  Query dormant sessions. Parameters:
182
211
  - `question` (required): the question to ask
183
- - `mode` (required): `project`, `global`, `session`, `self`, `ancestors`
184
- - `projectDir`: for project mode
212
+ - `mode` (required): `project`, `global`, `session`, `self`, `ancestors`, `subagents`, `branch`
213
+ - `projectDir`: for project/branch mode
185
214
  - `sessionId`: for session mode
186
215
  - `cwd`: working directory context
187
216
  - `limit`: max sessions/windows (default: 10)
188
217
  - `offset`: pagination offset
189
218
  - `copies`: for self mode, number of samples (default: 3)
190
219
  - `includeSubagents`: include subagent transcripts (default: false)
220
+ - `batchSize`: max concurrent API calls per batch (default: 5)
221
+ - `gitBranch`: for branch mode, explicit branch name
222
+
223
+ ### duncan_projects
224
+ List all CC projects with metadata. Parameters:
225
+ - `limit`: max projects (default: 50)
226
+ - `offset`: pagination offset
227
+
228
+ Returns: project cwd, session count, last activity, git branches.
191
229
 
192
230
  ### duncan_list_sessions
193
231
  List available sessions. Parameters:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gswangg/duncan-cc",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Query dormant Claude Code sessions — the Duncan Idaho approach to agent memory, for CC.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/discovery.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * Also discovers subagent transcripts.
10
10
  */
11
11
 
12
- import { readdirSync, statSync, existsSync, openSync, readSync, closeSync } from "node:fs";
12
+ import { readdirSync, statSync, existsSync, openSync, readSync, closeSync, readFileSync } from "node:fs";
13
13
  import { join, basename, dirname } from "node:path";
14
14
  import { homedir } from "node:os";
15
15
 
@@ -148,14 +148,144 @@ export function listSubagentFiles(sessionFile: string): SessionFileInfo[] {
148
148
  scanDir(sessionDir);
149
149
  } catch {}
150
150
 
151
+ // Sort by mtime, newest first (deterministic ordering)
152
+ files.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
151
153
  return files;
152
154
  }
153
155
 
156
+ // ============================================================================
157
+ // Git branch extraction
158
+ // ============================================================================
159
+
160
+ /** Size of head chunk to scan for gitBranch (bytes). */
161
+ const HEAD_SCAN_BYTES = 8_192;
162
+
163
+ /**
164
+ * Extract git branch(es) from a session file by scanning the head.
165
+ * Returns unique branches found. Scans only the first HEAD_SCAN_BYTES
166
+ * since gitBranch appears in the earliest entries.
167
+ */
168
+ export function extractGitBranches(filePath: string): string[] {
169
+ try {
170
+ const stat = statSync(filePath);
171
+ const readSize = Math.min(stat.size, HEAD_SCAN_BYTES);
172
+ const buf = Buffer.alloc(readSize);
173
+ const fd = openSync(filePath, "r");
174
+ readSync(fd, buf, 0, readSize, 0);
175
+ closeSync(fd);
176
+
177
+ const text = buf.toString("utf-8");
178
+ const branches = new Set<string>();
179
+ const re = /"gitBranch":"([^"]+)"/g;
180
+ let match;
181
+ while ((match = re.exec(text)) !== null) {
182
+ branches.add(match[1]);
183
+ }
184
+ return [...branches];
185
+ } catch {
186
+ return [];
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Extract the primary git branch for a session (first found).
192
+ */
193
+ export function extractGitBranch(filePath: string): string | null {
194
+ const branches = extractGitBranches(filePath);
195
+ return branches.length > 0 ? branches[0] : null;
196
+ }
197
+
198
+ // ============================================================================
199
+ // Project listing
200
+ // ============================================================================
201
+
202
+ export interface ProjectInfo {
203
+ /** The hashed project directory name (e.g., "-Users-foo-bar") */
204
+ dirName: string;
205
+ /** Full path to the project directory under ~/.claude/projects/ */
206
+ projectDir: string;
207
+ /** Reconstructed original working directory path */
208
+ cwd: string;
209
+ /** Number of sessions in this project */
210
+ sessionCount: number;
211
+ /** Most recent session activity */
212
+ lastActivity: Date;
213
+ /** Git branches seen across sessions */
214
+ branches: string[];
215
+ }
216
+
217
+ /**
218
+ * Reconstruct the original cwd from a CC project directory name.
219
+ * CC hashes cwd by replacing `/` with `-`, so `/Users/foo/bar` → `-Users-foo-bar`.
220
+ */
221
+ function cwdFromDirName(dirName: string): string {
222
+ // Replace leading - and all subsequent - with /
223
+ // This is lossy if the original path contained hyphens, but it's the best we can do
224
+ return dirName.replace(/-/g, "/");
225
+ }
226
+
227
+ /**
228
+ * List all CC projects with metadata.
229
+ */
230
+ export function listProjects(opts?: { limit?: number; offset?: number }): {
231
+ projects: ProjectInfo[];
232
+ totalCount: number;
233
+ hasMore: boolean;
234
+ } {
235
+ const projectsDir = getProjectsDir();
236
+ if (!existsSync(projectsDir)) {
237
+ return { projects: [], totalCount: 0, hasMore: false };
238
+ }
239
+
240
+ const allProjects: ProjectInfo[] = [];
241
+
242
+ try {
243
+ const dirs = readdirSync(projectsDir, { withFileTypes: true });
244
+ for (const d of dirs) {
245
+ if (!d.isDirectory()) continue;
246
+ const fullPath = join(projectsDir, d.name);
247
+ const sessions = listSessionFiles(fullPath);
248
+ if (sessions.length === 0) continue;
249
+
250
+ // Collect branches from recent sessions (scan up to 5 for efficiency)
251
+ const branchSet = new Set<string>();
252
+ for (const s of sessions.slice(0, 5)) {
253
+ for (const b of extractGitBranches(s.path)) {
254
+ branchSet.add(b);
255
+ }
256
+ }
257
+
258
+ allProjects.push({
259
+ dirName: d.name,
260
+ projectDir: fullPath,
261
+ cwd: cwdFromDirName(d.name),
262
+ sessionCount: sessions.length,
263
+ lastActivity: sessions[0].mtime, // sessions already sorted newest-first
264
+ branches: [...branchSet],
265
+ });
266
+ }
267
+ } catch {
268
+ return { projects: [], totalCount: 0, hasMore: false };
269
+ }
270
+
271
+ // Sort by last activity, newest first
272
+ allProjects.sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime());
273
+
274
+ const limit = opts?.limit ?? 50;
275
+ const offset = opts?.offset ?? 0;
276
+ const page = allProjects.slice(offset, offset + limit);
277
+ return {
278
+ projects: page,
279
+ totalCount: allProjects.length,
280
+ hasMore: offset + limit < allProjects.length,
281
+ };
282
+ }
283
+
154
284
  // ============================================================================
155
285
  // Routing
156
286
  // ============================================================================
157
287
 
158
- export type RoutingMode = "project" | "global" | "session" | "self" | "ancestors";
288
+ export type RoutingMode = "project" | "global" | "session" | "self" | "ancestors" | "subagents" | "branch";
159
289
 
160
290
  export interface RoutingParams {
161
291
  mode: RoutingMode;
@@ -171,6 +301,10 @@ export interface RoutingParams {
171
301
  limit?: number;
172
302
  /** Offset for pagination */
173
303
  offset?: number;
304
+ /** For "branch" mode: explicit git branch (auto-detected from calling session if omitted) */
305
+ gitBranch?: string;
306
+ /** Tool use ID from MCP _meta (used for self-exclusion and branch detection) */
307
+ toolUseId?: string;
174
308
  }
175
309
 
176
310
  export interface RoutingResult {
@@ -232,6 +366,45 @@ export function resolveSessionFiles(params: RoutingParams): RoutingResult {
232
366
  break;
233
367
  }
234
368
 
369
+ case "branch": {
370
+ // Find all sessions in the same project that share a git branch with the current session.
371
+ // Requires toolUseId to identify the calling session, or an explicit gitBranch param.
372
+ const projectDir = params.projectDir ?? (params.cwd ? getProjectDir(params.cwd) : null);
373
+ if (!projectDir) {
374
+ return { sessions: [], totalCount: 0, hasMore: false };
375
+ }
376
+
377
+ const projectSessions = listSessionFiles(projectDir);
378
+ let targetBranch: string | null = null;
379
+
380
+ // If we have a toolUseId, find the calling session's branch
381
+ if (params.toolUseId) {
382
+ const callingId = findCallingSession(params.toolUseId, projectSessions);
383
+ if (callingId) {
384
+ const callingSession = projectSessions.find(s => s.sessionId === callingId);
385
+ if (callingSession) {
386
+ targetBranch = extractGitBranch(callingSession.path);
387
+ }
388
+ }
389
+ }
390
+
391
+ // Fallback: use the explicit gitBranch param
392
+ if (!targetBranch && params.gitBranch) {
393
+ targetBranch = params.gitBranch;
394
+ }
395
+
396
+ if (!targetBranch) {
397
+ return { sessions: [], totalCount: 0, hasMore: false };
398
+ }
399
+
400
+ // Filter to sessions that share the same branch
401
+ allSessions = projectSessions.filter(s => {
402
+ const branches = extractGitBranches(s.path);
403
+ return branches.includes(targetBranch!);
404
+ });
405
+ break;
406
+ }
407
+
235
408
  default:
236
409
  return { sessions: [], totalCount: 0, hasMore: false };
237
410
  }
@@ -317,7 +490,7 @@ export function findCallingSession(
317
490
  * and exclude the calling session from results.
318
491
  */
319
492
  export function resolveSessionFilesExcludingSelf(
320
- params: RoutingParams & { toolUseId?: string },
493
+ params: RoutingParams,
321
494
  ): RoutingResult & { excludedSessionId: string | null } {
322
495
  const result = resolveSessionFiles(params);
323
496
 
package/src/mcp-server.ts CHANGED
@@ -19,15 +19,15 @@ import {
19
19
  } from "@modelcontextprotocol/sdk/types.js";
20
20
 
21
21
  import { processSessionFile, processSessionWindows } from "./pipeline.js";
22
- import { resolveSessionFiles, resolveSessionFilesExcludingSelf, getProjectsDir, listAllSessionFiles } from "./discovery.js";
23
- import { querySingleWindow, queryBatch, querySelf, queryAncestors } from "./query.js";
22
+ import { resolveSessionFiles, resolveSessionFilesExcludingSelf, getProjectsDir, listAllSessionFiles, listProjects, extractGitBranch } from "./discovery.js";
23
+ import { querySingleWindow, queryBatch, querySelf, queryAncestors, querySubagents } from "./query.js";
24
24
 
25
25
  // ============================================================================
26
26
  // Server setup
27
27
  // ============================================================================
28
28
 
29
29
  const server = new Server(
30
- { name: "duncan-cc", version: "0.1.0" },
30
+ { name: "duncan-cc", version: "0.2.0" },
31
31
  { capabilities: { tools: {} } },
32
32
  );
33
33
 
@@ -52,13 +52,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
52
52
  },
53
53
  mode: {
54
54
  type: "string",
55
- enum: ["project", "global", "session", "self", "ancestors"],
55
+ enum: ["project", "global", "session", "self", "ancestors", "subagents", "branch"],
56
56
  description:
57
57
  "Routing mode. 'project': sessions from a specific project dir. " +
58
58
  "'global': all sessions across all projects (newest first). " +
59
59
  "'session': a specific session file. " +
60
60
  "'self': query own active window N times for sampling diversity. " +
61
- "'ancestors': query own prior compaction windows (excluding active).",
61
+ "'ancestors': query own prior compaction windows (excluding active). " +
62
+ "'subagents': query subagent transcripts of the active session. " +
63
+ "'branch': sessions from the same project that share the current git branch.",
62
64
  },
63
65
  projectDir: {
64
66
  type: "string",
@@ -88,10 +90,39 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
88
90
  type: "number",
89
91
  description: "For 'self' mode: number of parallel queries for sampling diversity (default: 3).",
90
92
  },
93
+ batchSize: {
94
+ type: "number",
95
+ description: "Max concurrent API calls per batch (default: 5).",
96
+ },
97
+ gitBranch: {
98
+ type: "string",
99
+ description: "For 'branch' mode: explicit git branch name. If omitted, uses the calling session's branch.",
100
+ },
91
101
  },
92
102
  required: ["question", "mode"],
93
103
  },
94
104
  },
105
+ {
106
+ name: "duncan_projects",
107
+ description:
108
+ "List all Claude Code projects with metadata. Use to discover what projects exist " +
109
+ "before targeting a specific project with duncan_query. Returns project directories, " +
110
+ "session counts, last activity timestamps, and git branches.",
111
+ inputSchema: {
112
+ type: "object" as const,
113
+ properties: {
114
+ limit: {
115
+ type: "number",
116
+ description: "Max projects to list (default: 50).",
117
+ },
118
+ offset: {
119
+ type: "number",
120
+ description: "Pagination offset (default: 0).",
121
+ },
122
+ },
123
+ required: [],
124
+ },
125
+ },
95
126
  {
96
127
  name: "duncan_list_sessions",
97
128
  description: "List available Claude Code sessions. Use to discover sessions before querying.",
@@ -130,11 +161,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
130
161
  const { name, arguments: args, _meta } = request.params;
131
162
 
132
163
  // CC passes tool_use ID in _meta — used for self-exclusion
133
- const toolUseId = (_meta as Record<string, unknown> | undefined)?.["claudecode/toolUseId"] as string | undefined;
164
+ const meta = _meta as Record<string, unknown> | undefined;
165
+ const toolUseId = meta?.["claudecode/toolUseId"] as string | undefined;
166
+ const progressToken = meta?.progressToken as string | number | undefined;
134
167
 
135
168
  switch (name) {
136
169
  case "duncan_query":
137
- return handleDuncanQuery(args as any, toolUseId);
170
+ return handleDuncanQuery(args as any, toolUseId, progressToken);
171
+ case "duncan_projects":
172
+ return handleListProjects(args as any);
138
173
  case "duncan_list_sessions":
139
174
  return handleListSessions(args as any);
140
175
  default:
@@ -145,6 +180,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
145
180
  }
146
181
  });
147
182
 
183
+ /**
184
+ * Send an MCP progress notification if a progressToken was provided.
185
+ */
186
+ async function sendProgress(progressToken: string | number | undefined, progress: number, total: number): Promise<void> {
187
+ if (progressToken === undefined) return;
188
+ try {
189
+ await server.notification({
190
+ method: "notifications/progress",
191
+ params: { progressToken, progress, total },
192
+ });
193
+ } catch {
194
+ // Best-effort — don't let progress notifications break queries
195
+ }
196
+ }
197
+
148
198
  async function handleDuncanQuery(args: {
149
199
  question: string;
150
200
  mode: string;
@@ -155,7 +205,9 @@ async function handleDuncanQuery(args: {
155
205
  offset?: number;
156
206
  includeSubagents?: boolean;
157
207
  copies?: number;
158
- }, toolUseId?: string) {
208
+ batchSize?: number;
209
+ gitBranch?: string;
210
+ }, toolUseId?: string, progressToken?: string | number) {
159
211
  try {
160
212
  // Self mode: query own active window N times for sampling diversity
161
213
  if (args.mode === "self") {
@@ -168,7 +220,9 @@ async function handleDuncanQuery(args: {
168
220
  const result = await querySelf(args.question, {
169
221
  toolUseId,
170
222
  copies: args.copies ?? 3,
223
+ batchSize: args.batchSize,
171
224
  apiKey: undefined,
225
+ onProgress: (completed, total) => sendProgress(progressToken, completed, total),
172
226
  });
173
227
 
174
228
  if (result.results.length === 0) {
@@ -201,7 +255,9 @@ async function handleDuncanQuery(args: {
201
255
  toolUseId,
202
256
  limit: args.limit ?? 50,
203
257
  offset: args.offset ?? 0,
258
+ batchSize: args.batchSize,
204
259
  apiKey: undefined,
260
+ onProgress: (completed, total) => sendProgress(progressToken, completed, total),
205
261
  });
206
262
 
207
263
  if (result.results.length === 0) {
@@ -232,6 +288,58 @@ async function handleDuncanQuery(args: {
232
288
  return { content: [{ type: "text", text: parts.join("") }] };
233
289
  }
234
290
 
291
+ // Subagents mode: query subagent transcripts of the calling session
292
+ if (args.mode === "subagents") {
293
+ if (!toolUseId) {
294
+ return {
295
+ content: [{ type: "text", text: "Subagents mode requires toolUseId from _meta (only available when called from CC)." }],
296
+ isError: true,
297
+ };
298
+ }
299
+ const result = await querySubagents(args.question, {
300
+ toolUseId,
301
+ limit: args.limit ?? 50,
302
+ offset: args.offset ?? 0,
303
+ batchSize: args.batchSize,
304
+ apiKey: undefined,
305
+ onProgress: (completed, total) => sendProgress(progressToken, completed, total),
306
+ });
307
+
308
+ if (result.results.length === 0) {
309
+ return {
310
+ content: [{ type: "text", text: "No subagent transcripts found for this session." }],
311
+ };
312
+ }
313
+
314
+ const errors = result.results.filter(r => r.result.answer.startsWith("Error: "));
315
+ const nonErrors = result.results.filter(r => !r.result.answer.startsWith("Error: "));
316
+ const withContext = nonErrors.filter(r => r.result.hasContext);
317
+ const relevant = withContext.length > 0 ? withContext : nonErrors.length > 0 ? nonErrors : result.results;
318
+
319
+ const answers = relevant.map((r) => {
320
+ const label = relevant.length === 1 ? "" : `### ${r.sessionId.slice(0, 20)} (window ${r.windowIndex})\n`;
321
+ return `${label}${r.result.answer}\n*— ${r.model}*`;
322
+ }).join("\n\n---\n\n");
323
+
324
+ const parts = [`**${args.question}**\n\n${answers}`];
325
+
326
+ if (errors.length > 0) {
327
+ const errorLines = errors.map(r => `- ${r.sessionId.slice(0, 20)} (window ${r.windowIndex}): ${r.result.answer}`).join("\n");
328
+ parts.push(`\n\n---\n**${errors.length} error(s):**\n${errorLines}`);
329
+ }
330
+
331
+ if (result.hasMore) {
332
+ const nextOffset = (args.offset ?? 0) + (args.limit ?? 50);
333
+ const remaining = result.totalWindows - nextOffset;
334
+ parts.push(`\n\n---\n*Queried ${result.results.length} of ${result.totalWindows} windows. ${remaining} more available — call again with offset: ${nextOffset}.*`);
335
+ }
336
+
337
+ const contextCount = withContext.length;
338
+ parts.push(`\n\n*${contextCount}/${result.results.length} subagent windows had relevant context. queryId: ${result.queryId}*`);
339
+
340
+ return { content: [{ type: "text", text: parts.join("") }] };
341
+ }
342
+
235
343
  const result = await queryBatch(
236
344
  args.question,
237
345
  {
@@ -242,10 +350,13 @@ async function handleDuncanQuery(args: {
242
350
  limit: args.limit ?? 10,
243
351
  offset: args.offset ?? 0,
244
352
  includeSubagents: args.includeSubagents ?? false,
245
- toolUseId, // for self-exclusion
353
+ toolUseId, // for self-exclusion + branch detection
354
+ gitBranch: args.gitBranch,
246
355
  },
247
356
  {
248
357
  apiKey: undefined,
358
+ batchSize: args.batchSize,
359
+ onProgress: (completed, total) => sendProgress(progressToken, completed, total),
249
360
  },
250
361
  );
251
362
 
@@ -300,6 +411,37 @@ async function handleDuncanQuery(args: {
300
411
  }
301
412
  }
302
413
 
414
+ async function handleListProjects(args: {
415
+ limit?: number;
416
+ offset?: number;
417
+ }) {
418
+ try {
419
+ const result = listProjects({ limit: args.limit, offset: args.offset });
420
+
421
+ if (result.projects.length === 0) {
422
+ return {
423
+ content: [{ type: "text", text: "No projects found." }],
424
+ };
425
+ }
426
+
427
+ const lines = result.projects.map((p) => {
428
+ const date = p.lastActivity.toISOString().slice(0, 16);
429
+ const branches = p.branches.length > 0 ? p.branches.join(", ") : "—";
430
+ return `${p.cwd}\n sessions: ${p.sessionCount} last: ${date} branches: ${branches}`;
431
+ });
432
+
433
+ const header = `${result.totalCount} projects${result.hasMore ? ` (showing ${result.projects.length})` : ""}:`;
434
+ return {
435
+ content: [{ type: "text", text: `${header}\n\n${lines.join("\n\n")}` }],
436
+ };
437
+ } catch (err: any) {
438
+ return {
439
+ content: [{ type: "text", text: `Error listing projects: ${err.message}` }],
440
+ isError: true,
441
+ };
442
+ }
443
+ }
444
+
303
445
  async function handleListSessions(args: {
304
446
  mode: string;
305
447
  projectDir?: string;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Duncan Query Logger
3
+ *
4
+ * Appends structured records to ~/.claude/duncan.jsonl for every query.
5
+ * Captures tokens, latency, cache hits, routing strategy, and results.
6
+ * Append-only JSONL — easy to process with standard tools.
7
+ */
8
+
9
+ import { appendFileSync, mkdirSync, existsSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+
13
+ // ============================================================================
14
+ // Types
15
+ // ============================================================================
16
+
17
+ export interface DuncanLogRecord {
18
+ /** Batch ID grouping related queries */
19
+ batchId: string;
20
+ /** The question asked */
21
+ question: string;
22
+ /** The answer returned */
23
+ answer: string;
24
+ /** Whether the session had relevant context */
25
+ hasContext: boolean;
26
+ /** Target session ID */
27
+ targetSession: string;
28
+ /** Window index within the session */
29
+ windowIndex: number;
30
+ /** Source session ID (the calling session, if known) */
31
+ sourceSession: string | null;
32
+ /** Routing strategy used */
33
+ strategy: string;
34
+ /** Model used for the query */
35
+ model: string;
36
+ /** Input tokens consumed */
37
+ inputTokens: number;
38
+ /** Output tokens generated */
39
+ outputTokens: number;
40
+ /** Cache creation input tokens */
41
+ cacheCreationInputTokens: number;
42
+ /** Cache read input tokens */
43
+ cacheReadInputTokens: number;
44
+ /** Query latency in milliseconds */
45
+ latencyMs: number;
46
+ /** ISO timestamp */
47
+ timestamp: string;
48
+ }
49
+
50
+ // ============================================================================
51
+ // Logger
52
+ // ============================================================================
53
+
54
+ let logPath: string | null = null;
55
+
56
+ function getLogPath(): string {
57
+ if (!logPath) {
58
+ const claudeDir = join(homedir(), ".claude");
59
+ if (!existsSync(claudeDir)) {
60
+ mkdirSync(claudeDir, { recursive: true });
61
+ }
62
+ logPath = join(claudeDir, "duncan.jsonl");
63
+ }
64
+ return logPath;
65
+ }
66
+
67
+ /**
68
+ * Record a single query result to the log file.
69
+ */
70
+ export function recordQuery(record: DuncanLogRecord): void {
71
+ try {
72
+ const line = JSON.stringify(record) + "\n";
73
+ appendFileSync(getLogPath(), line, "utf-8");
74
+ } catch {
75
+ // Logging is best-effort — don't let it break queries
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Helper to create a log record from query context.
81
+ */
82
+ export function buildLogRecord(opts: {
83
+ batchId: string;
84
+ question: string;
85
+ answer: string;
86
+ hasContext: boolean;
87
+ targetSession: string;
88
+ windowIndex: number;
89
+ sourceSession?: string | null;
90
+ strategy: string;
91
+ model: string;
92
+ usage?: {
93
+ input_tokens?: number;
94
+ output_tokens?: number;
95
+ cache_creation_input_tokens?: number;
96
+ cache_read_input_tokens?: number;
97
+ };
98
+ latencyMs: number;
99
+ }): DuncanLogRecord {
100
+ return {
101
+ batchId: opts.batchId,
102
+ question: opts.question,
103
+ answer: opts.answer,
104
+ hasContext: opts.hasContext,
105
+ targetSession: opts.targetSession,
106
+ windowIndex: opts.windowIndex,
107
+ sourceSession: opts.sourceSession ?? null,
108
+ strategy: opts.strategy,
109
+ model: opts.model,
110
+ inputTokens: opts.usage?.input_tokens ?? 0,
111
+ outputTokens: opts.usage?.output_tokens ?? 0,
112
+ cacheCreationInputTokens: opts.usage?.cache_creation_input_tokens ?? 0,
113
+ cacheReadInputTokens: opts.usage?.cache_read_input_tokens ?? 0,
114
+ latencyMs: opts.latencyMs,
115
+ timestamp: new Date().toISOString(),
116
+ };
117
+ }
package/src/query.ts CHANGED
@@ -11,7 +11,8 @@ import { readFileSync, existsSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { homedir } from "node:os";
13
13
  import { processSessionFile, processSessionWindows, type PipelineResult, type WindowPipelineResult } from "./pipeline.js";
14
- import { resolveSessionFilesExcludingSelf, findCallingSession, listAllSessionFiles, type RoutingParams, type RoutingResult } from "./discovery.js";
14
+ import { resolveSessionFilesExcludingSelf, findCallingSession, listAllSessionFiles, listSubagentFiles, type RoutingParams, type RoutingResult } from "./discovery.js";
15
+ import { recordQuery, buildLogRecord } from "./query-logger.js";
15
16
 
16
17
  // ============================================================================
17
18
  // OAuth token resolution
@@ -65,7 +66,7 @@ function oauthClientConfig(token: string): ResolvedAuth {
65
66
  "accept": "application/json",
66
67
  "anthropic-dangerous-direct-browser-access": "true",
67
68
  "anthropic-beta": "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14",
68
- "user-agent": "duncan-cc/0.1.0",
69
+ "user-agent": "duncan-cc/0.2.0",
69
70
  "x-app": "cli",
70
71
  },
71
72
  };
@@ -105,6 +106,13 @@ const DUNCAN_PREFIX = `Answer solely based on the conversation above. If you don
105
106
  export interface DuncanResult {
106
107
  hasContext: boolean;
107
108
  answer: string;
109
+ usage?: {
110
+ input_tokens: number;
111
+ output_tokens: number;
112
+ cache_creation_input_tokens?: number;
113
+ cache_read_input_tokens?: number;
114
+ };
115
+ latencyMs?: number;
108
116
  }
109
117
 
110
118
  export interface DuncanQueryResult {
@@ -190,6 +198,8 @@ export async function querySingleWindow(
190
198
  } as any);
191
199
  }
192
200
 
201
+ const startTime = Date.now();
202
+
193
203
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
194
204
  const response = await client.messages.create({
195
205
  model,
@@ -207,7 +217,13 @@ export async function querySingleWindow(
207
217
  if (toolCall) {
208
218
  const input = toolCall.input as { hasContext: boolean; answer: string };
209
219
  if (typeof input.hasContext === "boolean" && typeof input.answer === "string") {
210
- return { hasContext: input.hasContext, answer: input.answer };
220
+ const latencyMs = Date.now() - startTime;
221
+ return {
222
+ hasContext: input.hasContext,
223
+ answer: input.answer,
224
+ usage: response.usage as any,
225
+ latencyMs,
226
+ };
211
227
  }
212
228
  }
213
229
 
@@ -226,6 +242,35 @@ export async function querySingleWindow(
226
242
  throw new Error(`Duncan query failed after ${MAX_RETRIES} retries: model did not produce a valid duncan_response tool call`);
227
243
  }
228
244
 
245
+ // ============================================================================
246
+ // Logging Helper
247
+ // ============================================================================
248
+
249
+ /**
250
+ * Log all results in a batch to ~/.claude/duncan.jsonl.
251
+ */
252
+ function logBatchResults(
253
+ batchResult: DuncanBatchResult,
254
+ strategy: string,
255
+ sourceSession: string | null,
256
+ ): void {
257
+ for (const r of batchResult.results) {
258
+ recordQuery(buildLogRecord({
259
+ batchId: batchResult.queryId,
260
+ question: batchResult.question,
261
+ answer: r.result.answer,
262
+ hasContext: r.result.hasContext,
263
+ targetSession: r.sessionId,
264
+ windowIndex: r.windowIndex,
265
+ sourceSession,
266
+ strategy,
267
+ model: r.model,
268
+ usage: r.result.usage,
269
+ latencyMs: r.result.latencyMs ?? 0,
270
+ }));
271
+ }
272
+ }
273
+
229
274
  // ============================================================================
230
275
  // Batch Query
231
276
  // ============================================================================
@@ -235,7 +280,7 @@ export async function querySingleWindow(
235
280
  */
236
281
  export async function queryBatch(
237
282
  question: string,
238
- routing: RoutingParams & { toolUseId?: string },
283
+ routing: RoutingParams,
239
284
  opts: {
240
285
  apiKey?: string;
241
286
  model?: string;
@@ -328,7 +373,7 @@ export async function queryBatch(
328
373
  results.push(...batchResults);
329
374
  }
330
375
 
331
- return {
376
+ const batchResult: DuncanBatchResult = {
332
377
  queryId,
333
378
  question,
334
379
  results,
@@ -336,6 +381,8 @@ export async function queryBatch(
336
381
  hasMore: resolved.hasMore,
337
382
  offset: routing.offset ?? 0,
338
383
  };
384
+ logBatchResults(batchResult, routing.mode, resolved.excludedSessionId);
385
+ return batchResult;
339
386
  }
340
387
 
341
388
  // ============================================================================
@@ -434,7 +481,9 @@ export async function querySelf(
434
481
  // Wave 1: prime the cache with a single query
435
482
  results.push(await queryOnce());
436
483
  if (opts.signal?.aborted || copies <= 1) {
437
- return { queryId, question, results, totalWindows: total, hasMore: false, offset: 0 };
484
+ const batchResult: DuncanBatchResult = { queryId, question, results, totalWindows: total, hasMore: false, offset: 0 };
485
+ logBatchResults(batchResult, "self", callingSessionId);
486
+ return batchResult;
438
487
  }
439
488
 
440
489
  // Wave 2: remaining copies in batches, hitting cached prefix
@@ -449,7 +498,9 @@ export async function querySelf(
449
498
  results.push(...batchResults);
450
499
  }
451
500
 
452
- return { queryId, question, results, totalWindows: total, hasMore: false, offset: 0 };
501
+ const batchResult: DuncanBatchResult = { queryId, question, results, totalWindows: total, hasMore: false, offset: 0 };
502
+ logBatchResults(batchResult, "self", callingSessionId);
503
+ return batchResult;
453
504
  }
454
505
 
455
506
  // ============================================================================
@@ -546,7 +597,7 @@ export async function queryAncestors(
546
597
  results.push(...batchResults);
547
598
  }
548
599
 
549
- return {
600
+ const batchResult: DuncanBatchResult = {
550
601
  queryId,
551
602
  question,
552
603
  results,
@@ -554,6 +605,112 @@ export async function queryAncestors(
554
605
  hasMore: offset + limit < totalWindows,
555
606
  offset,
556
607
  };
608
+ logBatchResults(batchResult, "ancestors", callingSessionId);
609
+ return batchResult;
610
+ }
611
+
612
+ // ============================================================================
613
+ // Subagents Query — subagent transcripts of the active session
614
+ // ============================================================================
615
+
616
+ /**
617
+ * Query the calling session's subagent transcripts.
618
+ */
619
+ export async function querySubagents(
620
+ question: string,
621
+ opts: {
622
+ toolUseId: string;
623
+ limit?: number;
624
+ offset?: number;
625
+ batchSize?: number;
626
+ apiKey?: string;
627
+ model?: string;
628
+ signal?: AbortSignal;
629
+ onProgress?: (completed: number, total: number) => void;
630
+ },
631
+ ): Promise<DuncanBatchResult> {
632
+ const queryId = randomUUID();
633
+ const limit = opts.limit ?? 50;
634
+ const offset = opts.offset ?? 0;
635
+
636
+ const allSessions = listAllSessionFiles();
637
+ const callingSessionId = findCallingSession(opts.toolUseId, allSessions);
638
+ if (!callingSessionId) {
639
+ return { queryId, question, results: [], totalWindows: 0, hasMore: false, offset };
640
+ }
641
+
642
+ const session = allSessions.find(s => s.sessionId === callingSessionId);
643
+ if (!session) {
644
+ return { queryId, question, results: [], totalWindows: 0, hasMore: false, offset };
645
+ }
646
+
647
+ const subagentFiles = listSubagentFiles(session.path);
648
+ if (subagentFiles.length === 0) {
649
+ return { queryId, question, results: [], totalWindows: 0, hasMore: false, offset };
650
+ }
651
+
652
+ // Expand subagent files to windows
653
+ const allTargets: Array<{ sessionFile: string; sessionId: string; pipeline: WindowPipelineResult }> = [];
654
+ for (const sub of subagentFiles) {
655
+ try {
656
+ const windows = processSessionWindows(sub.path);
657
+ for (const w of windows) {
658
+ if (w.messages.length === 0) continue;
659
+ allTargets.push({ sessionFile: sub.path, sessionId: sub.sessionId, pipeline: w });
660
+ }
661
+ } catch {}
662
+ }
663
+
664
+ if (allTargets.length === 0) {
665
+ return { queryId, question, results: [], totalWindows: 0, hasMore: false, offset };
666
+ }
667
+
668
+ const totalWindows = allTargets.length;
669
+ const page = allTargets.slice(offset, offset + limit);
670
+
671
+ const batchSize = opts.batchSize ?? 5;
672
+ const results: DuncanQueryResult[] = [];
673
+ let completed = 0;
674
+
675
+ for (let i = 0; i < page.length; i += batchSize) {
676
+ if (opts.signal?.aborted) break;
677
+ const batch = page.slice(i, i + batchSize);
678
+ const batchResults = await Promise.all(
679
+ batch.map(async (target) => {
680
+ try {
681
+ const result = await querySingleWindow(target.pipeline, question, {
682
+ apiKey: opts.apiKey,
683
+ model: opts.model ?? target.pipeline.modelInfo?.modelId,
684
+ signal: opts.signal,
685
+ });
686
+ completed++;
687
+ opts.onProgress?.(completed, page.length);
688
+ return {
689
+ queryId, sessionFile: target.sessionFile, sessionId: target.sessionId,
690
+ windowIndex: target.pipeline.windowIndex,
691
+ model: target.pipeline.modelInfo?.modelId ?? "unknown", result,
692
+ };
693
+ } catch (err: any) {
694
+ completed++;
695
+ opts.onProgress?.(completed, page.length);
696
+ return {
697
+ queryId, sessionFile: target.sessionFile, sessionId: target.sessionId,
698
+ windowIndex: target.pipeline.windowIndex,
699
+ model: target.pipeline.modelInfo?.modelId ?? "unknown",
700
+ result: { hasContext: false, answer: `Error: ${err.message}` },
701
+ };
702
+ }
703
+ }),
704
+ );
705
+ results.push(...batchResults);
706
+ }
707
+
708
+ const batchResult: DuncanBatchResult = {
709
+ queryId, question, results, totalWindows,
710
+ hasMore: offset + limit < totalWindows, offset,
711
+ };
712
+ logBatchResults(batchResult, "subagents", callingSessionId);
713
+ return batchResult;
557
714
  }
558
715
 
559
716
  // ============================================================================