@gswangg/duncan-cc 0.1.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.
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Session Discovery
3
+ *
4
+ * Finds CC session files on disk, supports routing modes:
5
+ * - project: all sessions in the same project dir (same cwd)
6
+ * - global: all sessions across all projects
7
+ * - specific: a named session file
8
+ *
9
+ * Also discovers subagent transcripts.
10
+ */
11
+
12
+ import { readdirSync, statSync, existsSync, openSync, readSync, closeSync } from "node:fs";
13
+ import { join, basename, dirname } from "node:path";
14
+ import { homedir } from "node:os";
15
+
16
+ // ============================================================================
17
+ // Path resolution — mirrors CC's path layout
18
+ // ============================================================================
19
+
20
+ /** Get CC's projects directory */
21
+ export function getProjectsDir(): string {
22
+ return join(homedir(), ".claude", "projects");
23
+ }
24
+
25
+ /** Get project dir for a given cwd (hashed) */
26
+ export function getProjectDir(cwd: string): string | null {
27
+ const projectsDir = getProjectsDir();
28
+ if (!existsSync(projectsDir)) return null;
29
+
30
+ // CC hashes the cwd into the directory name
31
+ // The format is: the cwd with / replaced by - and leading - stripped
32
+ // e.g., /Users/foo/bar → -Users-foo-bar
33
+ const hashed = cwd.replace(/\//g, "-");
34
+
35
+ const fullPath = join(projectsDir, hashed);
36
+ if (existsSync(fullPath)) return fullPath;
37
+
38
+ // Try to find by scanning (the hash might differ slightly)
39
+ try {
40
+ const dirs = readdirSync(projectsDir, { withFileTypes: true });
41
+ for (const d of dirs) {
42
+ if (!d.isDirectory()) continue;
43
+ // Check if the directory name ends with the last component(s) of cwd
44
+ if (d.name === hashed || d.name.endsWith(hashed)) {
45
+ return join(projectsDir, d.name);
46
+ }
47
+ }
48
+ } catch {}
49
+
50
+ return null;
51
+ }
52
+
53
+ // ============================================================================
54
+ // Session file discovery
55
+ // ============================================================================
56
+
57
+ export interface SessionFileInfo {
58
+ path: string;
59
+ sessionId: string;
60
+ mtime: Date;
61
+ size: number;
62
+ projectDir: string;
63
+ }
64
+
65
+ /** List all session files in a project directory */
66
+ export function listSessionFiles(projectDir: string): SessionFileInfo[] {
67
+ if (!existsSync(projectDir)) return [];
68
+
69
+ try {
70
+ const entries = readdirSync(projectDir, { withFileTypes: true });
71
+ const sessions: SessionFileInfo[] = [];
72
+
73
+ for (const entry of entries) {
74
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
75
+ const fullPath = join(projectDir, entry.name);
76
+ try {
77
+ const stat = statSync(fullPath);
78
+ sessions.push({
79
+ path: fullPath,
80
+ sessionId: entry.name.replace(".jsonl", ""),
81
+ mtime: stat.mtime,
82
+ size: stat.size,
83
+ projectDir,
84
+ });
85
+ } catch {}
86
+ }
87
+
88
+ // Sort by mtime, newest first
89
+ sessions.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
90
+ return sessions;
91
+ } catch {
92
+ return [];
93
+ }
94
+ }
95
+
96
+ /** List all session files across all projects */
97
+ export function listAllSessionFiles(): SessionFileInfo[] {
98
+ const projectsDir = getProjectsDir();
99
+ if (!existsSync(projectsDir)) return [];
100
+
101
+ const allSessions: SessionFileInfo[] = [];
102
+
103
+ try {
104
+ const projectDirs = readdirSync(projectsDir, { withFileTypes: true });
105
+ for (const d of projectDirs) {
106
+ if (!d.isDirectory()) continue;
107
+ const sessions = listSessionFiles(join(projectsDir, d.name));
108
+ allSessions.push(...sessions);
109
+ }
110
+ } catch {}
111
+
112
+ // Sort by mtime, newest first
113
+ allSessions.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
114
+ return allSessions;
115
+ }
116
+
117
+ /** List subagent transcript files for a session */
118
+ export function listSubagentFiles(sessionFile: string): SessionFileInfo[] {
119
+ const sessionId = basename(sessionFile, ".jsonl");
120
+ const sessionDir = join(dirname(sessionFile), sessionId, "subagents");
121
+
122
+ if (!existsSync(sessionDir)) return [];
123
+
124
+ const files: SessionFileInfo[] = [];
125
+
126
+ try {
127
+ // Subagents can be in subdirectories
128
+ function scanDir(dir: string) {
129
+ const entries = readdirSync(dir, { withFileTypes: true });
130
+ for (const entry of entries) {
131
+ const fullPath = join(dir, entry.name);
132
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
133
+ try {
134
+ const stat = statSync(fullPath);
135
+ files.push({
136
+ path: fullPath,
137
+ sessionId: entry.name.replace(".jsonl", ""),
138
+ mtime: stat.mtime,
139
+ size: stat.size,
140
+ projectDir: dirname(sessionFile),
141
+ });
142
+ } catch {}
143
+ } else if (entry.isDirectory()) {
144
+ scanDir(fullPath);
145
+ }
146
+ }
147
+ }
148
+ scanDir(sessionDir);
149
+ } catch {}
150
+
151
+ return files;
152
+ }
153
+
154
+ // ============================================================================
155
+ // Routing
156
+ // ============================================================================
157
+
158
+ export type RoutingMode = "project" | "global" | "session" | "self" | "ancestors";
159
+
160
+ export interface RoutingParams {
161
+ mode: RoutingMode;
162
+ /** For "project" mode: the cwd to find sessions for */
163
+ cwd?: string;
164
+ /** For "project" mode: explicit project dir path */
165
+ projectDir?: string;
166
+ /** For "session" mode: specific session file path or ID */
167
+ sessionId?: string;
168
+ /** Include subagent transcripts */
169
+ includeSubagents?: boolean;
170
+ /** Max sessions to return */
171
+ limit?: number;
172
+ /** Offset for pagination */
173
+ offset?: number;
174
+ }
175
+
176
+ export interface RoutingResult {
177
+ sessions: SessionFileInfo[];
178
+ totalCount: number;
179
+ hasMore: boolean;
180
+ }
181
+
182
+ /** Resolve session files based on routing parameters */
183
+ export function resolveSessionFiles(params: RoutingParams): RoutingResult {
184
+ const limit = params.limit ?? 50;
185
+ const offset = params.offset ?? 0;
186
+ let allSessions: SessionFileInfo[];
187
+
188
+ switch (params.mode) {
189
+ case "project": {
190
+ const projectDir = params.projectDir ?? (params.cwd ? getProjectDir(params.cwd) : null);
191
+ if (!projectDir) {
192
+ return { sessions: [], totalCount: 0, hasMore: false };
193
+ }
194
+ allSessions = listSessionFiles(projectDir);
195
+
196
+ // Optionally include subagents
197
+ if (params.includeSubagents) {
198
+ const withSubagents: SessionFileInfo[] = [];
199
+ for (const s of allSessions) {
200
+ withSubagents.push(s);
201
+ withSubagents.push(...listSubagentFiles(s.path));
202
+ }
203
+ allSessions = withSubagents;
204
+ }
205
+ break;
206
+ }
207
+
208
+ case "global": {
209
+ allSessions = listAllSessionFiles();
210
+ break;
211
+ }
212
+
213
+ case "session": {
214
+ if (!params.sessionId) {
215
+ return { sessions: [], totalCount: 0, hasMore: false };
216
+ }
217
+ // Try as full path first
218
+ if (existsSync(params.sessionId)) {
219
+ const stat = statSync(params.sessionId);
220
+ allSessions = [{
221
+ path: params.sessionId,
222
+ sessionId: basename(params.sessionId, ".jsonl"),
223
+ mtime: stat.mtime,
224
+ size: stat.size,
225
+ projectDir: dirname(params.sessionId),
226
+ }];
227
+ } else {
228
+ // Try to find by session ID across all projects
229
+ const all = listAllSessionFiles();
230
+ allSessions = all.filter((s) => s.sessionId === params.sessionId);
231
+ }
232
+ break;
233
+ }
234
+
235
+ default:
236
+ return { sessions: [], totalCount: 0, hasMore: false };
237
+ }
238
+
239
+ const totalCount = allSessions.length;
240
+ const sessions = allSessions.slice(offset, offset + limit);
241
+ const hasMore = offset + limit < totalCount;
242
+
243
+ return { sessions, totalCount, hasMore };
244
+ }
245
+
246
+ // ============================================================================
247
+ // Self-exclusion — find the calling session by toolUseId
248
+ // ============================================================================
249
+
250
+ /** Size of tail chunk to scan for toolUseId (bytes). */
251
+ const TAIL_SCAN_BYTES = 32_768;
252
+
253
+ /**
254
+ * Scan the tail of a JSONL file for a tool_use ID string.
255
+ * Returns true if found. Only reads the last TAIL_SCAN_BYTES of the file
256
+ * to keep I/O minimal — the tool_use that triggered the MCP call will be
257
+ * near the very end of the active session file.
258
+ */
259
+ function tailContains(filePath: string, needle: string): boolean {
260
+ let fd: number | undefined;
261
+ try {
262
+ const stat = statSync(filePath);
263
+ const size = stat.size;
264
+ if (size === 0) return false;
265
+
266
+ const readSize = Math.min(size, TAIL_SCAN_BYTES);
267
+ const offset = size - readSize;
268
+ const buf = Buffer.alloc(readSize);
269
+
270
+ fd = openSync(filePath, "r");
271
+ readSync(fd, buf, 0, readSize, offset);
272
+ closeSync(fd);
273
+ fd = undefined;
274
+
275
+ return buf.includes(needle);
276
+ } catch {
277
+ return false;
278
+ } finally {
279
+ if (fd !== undefined) {
280
+ try { closeSync(fd); } catch {}
281
+ }
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Find the session that contains a specific tool_use ID.
287
+ *
288
+ * When CC calls an MCP tool, it writes the assistant message (containing
289
+ * the tool_use block) to the session JSONL *before* invoking the tool.
290
+ * CC passes the tool_use ID in the MCP request's `_meta` field as
291
+ * `"claudecode/toolUseId"`. We scan the tail of candidate session files
292
+ * to find the one containing that ID — that's the calling session.
293
+ *
294
+ * @param toolUseId The tool_use ID from MCP request `_meta`
295
+ * @param candidates Session files to search (pre-filtered by routing mode)
296
+ * @returns The matching session's ID, or null if not found
297
+ */
298
+ export function findCallingSession(
299
+ toolUseId: string,
300
+ candidates: SessionFileInfo[],
301
+ ): string | null {
302
+ if (!toolUseId) return null;
303
+
304
+ for (const session of candidates) {
305
+ if (tailContains(session.path, toolUseId)) {
306
+ return session.sessionId;
307
+ }
308
+ }
309
+
310
+ return null;
311
+ }
312
+
313
+ /**
314
+ * Resolve session files with self-exclusion.
315
+ *
316
+ * Like `resolveSessionFiles` but also accepts a `toolUseId` to identify
317
+ * and exclude the calling session from results.
318
+ */
319
+ export function resolveSessionFilesExcludingSelf(
320
+ params: RoutingParams & { toolUseId?: string },
321
+ ): RoutingResult & { excludedSessionId: string | null } {
322
+ const result = resolveSessionFiles(params);
323
+
324
+ if (!params.toolUseId || result.sessions.length === 0) {
325
+ return { ...result, excludedSessionId: null };
326
+ }
327
+
328
+ const excludedId = findCallingSession(params.toolUseId, result.sessions);
329
+ if (!excludedId) {
330
+ return { ...result, excludedSessionId: null };
331
+ }
332
+
333
+ const filtered = result.sessions.filter((s) => s.sessionId !== excludedId);
334
+ return {
335
+ sessions: filtered,
336
+ totalCount: result.totalCount - 1,
337
+ hasMore: result.hasMore,
338
+ excludedSessionId: excludedId,
339
+ };
340
+ }
@@ -0,0 +1,356 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Duncan CC — MCP Server
4
+ *
5
+ * Exposes duncan session querying as an MCP tool that Claude Code can call.
6
+ * Uses stdio transport.
7
+ *
8
+ * Usage:
9
+ * npx tsx src/mcp-server.ts
10
+ * # or after build:
11
+ * node dist/mcp-server.js
12
+ */
13
+
14
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
+ import {
17
+ CallToolRequestSchema,
18
+ ListToolsRequestSchema,
19
+ } from "@modelcontextprotocol/sdk/types.js";
20
+
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";
24
+
25
+ // ============================================================================
26
+ // Server setup
27
+ // ============================================================================
28
+
29
+ const server = new Server(
30
+ { name: "duncan-cc", version: "0.1.0" },
31
+ { capabilities: { tools: {} } },
32
+ );
33
+
34
+ // ============================================================================
35
+ // Tool definitions
36
+ // ============================================================================
37
+
38
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
39
+ tools: [
40
+ {
41
+ name: "duncan_query",
42
+ description:
43
+ "Query dormant Claude Code sessions to recall information from previous conversations. " +
44
+ "Loads session context and asks the target session's model whether it has relevant information. " +
45
+ "Use when you need to find something discussed in a previous CC session.",
46
+ inputSchema: {
47
+ type: "object" as const,
48
+ properties: {
49
+ question: {
50
+ type: "string",
51
+ description: "The question to ask previous sessions. Be specific and self-contained.",
52
+ },
53
+ mode: {
54
+ type: "string",
55
+ enum: ["project", "global", "session", "self", "ancestors"],
56
+ description:
57
+ "Routing mode. 'project': sessions from a specific project dir. " +
58
+ "'global': all sessions across all projects (newest first). " +
59
+ "'session': a specific session file. " +
60
+ "'self': query own active window N times for sampling diversity. " +
61
+ "'ancestors': query own prior compaction windows (excluding active).",
62
+ },
63
+ projectDir: {
64
+ type: "string",
65
+ description: "For 'project' mode: explicit project directory path. If omitted, uses cwd.",
66
+ },
67
+ sessionId: {
68
+ type: "string",
69
+ description: "For 'session' mode: session file path or session ID.",
70
+ },
71
+ cwd: {
72
+ type: "string",
73
+ description: "Working directory for context resolution (CLAUDE.md, git status). Defaults to process.cwd().",
74
+ },
75
+ limit: {
76
+ type: "number",
77
+ description: "Max sessions to query (default: 10).",
78
+ },
79
+ offset: {
80
+ type: "number",
81
+ description: "Skip this many sessions for pagination (default: 0).",
82
+ },
83
+ includeSubagents: {
84
+ type: "boolean",
85
+ description: "Include subagent transcripts in search (default: false).",
86
+ },
87
+ copies: {
88
+ type: "number",
89
+ description: "For 'self' mode: number of parallel queries for sampling diversity (default: 3).",
90
+ },
91
+ },
92
+ required: ["question", "mode"],
93
+ },
94
+ },
95
+ {
96
+ name: "duncan_list_sessions",
97
+ description: "List available Claude Code sessions. Use to discover sessions before querying.",
98
+ inputSchema: {
99
+ type: "object" as const,
100
+ properties: {
101
+ mode: {
102
+ type: "string",
103
+ enum: ["project", "global"],
104
+ description: "'project': sessions from a project dir. 'global': all sessions.",
105
+ },
106
+ projectDir: {
107
+ type: "string",
108
+ description: "For 'project' mode: project directory path.",
109
+ },
110
+ cwd: {
111
+ type: "string",
112
+ description: "For 'project' mode: working directory to resolve project dir.",
113
+ },
114
+ limit: {
115
+ type: "number",
116
+ description: "Max sessions to list (default: 20).",
117
+ },
118
+ },
119
+ required: ["mode"],
120
+ },
121
+ },
122
+ ],
123
+ }));
124
+
125
+ // ============================================================================
126
+ // Tool handlers
127
+ // ============================================================================
128
+
129
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
130
+ const { name, arguments: args, _meta } = request.params;
131
+
132
+ // 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;
134
+
135
+ switch (name) {
136
+ case "duncan_query":
137
+ return handleDuncanQuery(args as any, toolUseId);
138
+ case "duncan_list_sessions":
139
+ return handleListSessions(args as any);
140
+ default:
141
+ return {
142
+ content: [{ type: "text", text: `Unknown tool: ${name}` }],
143
+ isError: true,
144
+ };
145
+ }
146
+ });
147
+
148
+ async function handleDuncanQuery(args: {
149
+ question: string;
150
+ mode: string;
151
+ projectDir?: string;
152
+ sessionId?: string;
153
+ cwd?: string;
154
+ limit?: number;
155
+ offset?: number;
156
+ includeSubagents?: boolean;
157
+ copies?: number;
158
+ }, toolUseId?: string) {
159
+ try {
160
+ // Self mode: query own active window N times for sampling diversity
161
+ if (args.mode === "self") {
162
+ if (!toolUseId) {
163
+ return {
164
+ content: [{ type: "text", text: "Self mode requires toolUseId from _meta (only available when called from CC)." }],
165
+ isError: true,
166
+ };
167
+ }
168
+ const result = await querySelf(args.question, {
169
+ toolUseId,
170
+ copies: args.copies ?? 3,
171
+ apiKey: undefined,
172
+ });
173
+
174
+ if (result.results.length === 0) {
175
+ return {
176
+ content: [{ type: "text", text: "Could not find calling session for self-query." }],
177
+ isError: true,
178
+ };
179
+ }
180
+
181
+ // Format: show all N answers
182
+ const answers = result.results.map((r, i) => {
183
+ return `### Sample ${i + 1}\n${r.result.answer}\n*— ${r.model}*`;
184
+ }).join("\n\n---\n\n");
185
+
186
+ const contextCount = result.results.filter(r => r.result.hasContext).length;
187
+ return {
188
+ content: [{ type: "text", text: `**${args.question}** (${result.results.length} samples)\n\n${answers}\n\n*${contextCount}/${result.results.length} had relevant context. queryId: ${result.queryId}*` }],
189
+ };
190
+ }
191
+
192
+ // Ancestors mode: query prior compaction windows of the calling session
193
+ if (args.mode === "ancestors") {
194
+ if (!toolUseId) {
195
+ return {
196
+ content: [{ type: "text", text: "Ancestors mode requires toolUseId from _meta (only available when called from CC)." }],
197
+ isError: true,
198
+ };
199
+ }
200
+ const result = await queryAncestors(args.question, {
201
+ toolUseId,
202
+ limit: args.limit ?? 50,
203
+ offset: args.offset ?? 0,
204
+ apiKey: undefined,
205
+ });
206
+
207
+ if (result.results.length === 0) {
208
+ return {
209
+ content: [{ type: "text", text: "No ancestor windows found. This session has no compaction boundaries." }],
210
+ };
211
+ }
212
+
213
+ const withContext = result.results.filter(r => r.result.hasContext);
214
+ const relevant = withContext.length > 0 ? withContext : result.results;
215
+
216
+ const answers = relevant.map((r) => {
217
+ const label = relevant.length === 1 ? "" : `### Window ${r.windowIndex}\n`;
218
+ return `${label}${r.result.answer}\n*— ${r.model}*`;
219
+ }).join("\n\n---\n\n");
220
+
221
+ const parts = [`**${args.question}**\n\n${answers}`];
222
+
223
+ if (result.hasMore) {
224
+ const nextOffset = (args.offset ?? 0) + (args.limit ?? 50);
225
+ const remaining = result.totalWindows - nextOffset;
226
+ parts.push(`\n\n---\n*Queried ${result.results.length} of ${result.totalWindows} windows. ${remaining} more available — call again with offset: ${nextOffset}.*`);
227
+ }
228
+
229
+ const contextCount = withContext.length;
230
+ parts.push(`\n\n*${contextCount}/${result.results.length} windows had relevant context. queryId: ${result.queryId}*`);
231
+
232
+ return { content: [{ type: "text", text: parts.join("") }] };
233
+ }
234
+
235
+ const result = await queryBatch(
236
+ args.question,
237
+ {
238
+ mode: args.mode as any,
239
+ projectDir: args.projectDir,
240
+ sessionId: args.sessionId,
241
+ cwd: args.cwd,
242
+ limit: args.limit ?? 10,
243
+ offset: args.offset ?? 0,
244
+ includeSubagents: args.includeSubagents ?? false,
245
+ toolUseId, // for self-exclusion
246
+ },
247
+ {
248
+ apiKey: undefined,
249
+ },
250
+ );
251
+
252
+ if (result.results.length === 0) {
253
+ return {
254
+ content: [{ type: "text", text: "No sessions found matching the routing criteria." }],
255
+ };
256
+ }
257
+
258
+ const errors = result.results.filter((r) => r.result.answer.startsWith("Error: "));
259
+ const nonErrors = result.results.filter((r) => !r.result.answer.startsWith("Error: "));
260
+ const withContext = nonErrors.filter((r) => r.result.hasContext);
261
+ const relevant = withContext.length > 0 ? withContext : nonErrors.length > 0 ? nonErrors : result.results;
262
+
263
+ const answers = relevant
264
+ .map((r) => {
265
+ const label = relevant.length === 1
266
+ ? ""
267
+ : `### ${r.sessionId.slice(0, 12)} (window ${r.windowIndex})\n`;
268
+ const modelLine = `\n*— ${r.model}*`;
269
+ return `${label}${r.result.answer}${modelLine}`;
270
+ })
271
+ .join("\n\n---\n\n");
272
+
273
+ const parts = [`**${args.question}**\n\n${answers}`];
274
+
275
+ if (errors.length > 0) {
276
+ const errorLines = errors.map((r) => `- ${r.sessionId.slice(0, 12)} (window ${r.windowIndex}): ${r.result.answer}`).join("\n");
277
+ parts.push(`\n\n---\n**${errors.length} error(s):**\n${errorLines}`);
278
+ }
279
+
280
+ if (result.hasMore) {
281
+ const nextOffset = (args.offset ?? 0) + (args.limit ?? 10);
282
+ const remaining = result.totalWindows - nextOffset;
283
+ parts.push(
284
+ `\n\n---\n*Queried ${result.results.length} of ${result.totalWindows} windows. ${remaining} more available — call again with offset: ${nextOffset}.*`,
285
+ );
286
+ }
287
+
288
+ const contextCount = withContext.length;
289
+ const totalCount = result.results.length;
290
+ parts.push(`\n\n*${contextCount}/${totalCount} sessions had relevant context. queryId: ${result.queryId}*`);
291
+
292
+ return {
293
+ content: [{ type: "text", text: parts.join("") }],
294
+ };
295
+ } catch (err: any) {
296
+ return {
297
+ content: [{ type: "text", text: `Duncan query error: ${err.message}` }],
298
+ isError: true,
299
+ };
300
+ }
301
+ }
302
+
303
+ async function handleListSessions(args: {
304
+ mode: string;
305
+ projectDir?: string;
306
+ cwd?: string;
307
+ limit?: number;
308
+ }) {
309
+ try {
310
+ const resolved = resolveSessionFiles({
311
+ mode: args.mode as any,
312
+ projectDir: args.projectDir,
313
+ cwd: args.cwd,
314
+ limit: args.limit ?? 20,
315
+ });
316
+
317
+ if (resolved.sessions.length === 0) {
318
+ return {
319
+ content: [{ type: "text", text: "No sessions found." }],
320
+ };
321
+ }
322
+
323
+ const lines = resolved.sessions.map((s) => {
324
+ const date = s.mtime.toISOString().slice(0, 16);
325
+ const size = s.size > 1024 * 1024
326
+ ? `${(s.size / 1024 / 1024).toFixed(1)}MB`
327
+ : `${(s.size / 1024).toFixed(0)}KB`;
328
+ return `${s.sessionId.slice(0, 12)} ${date} ${size} ${s.projectDir.split("/").slice(-1)[0]}`;
329
+ });
330
+
331
+ const header = `${resolved.totalCount} sessions${resolved.hasMore ? ` (showing ${resolved.sessions.length})` : ""}:`;
332
+ return {
333
+ content: [{ type: "text", text: `${header}\n\n${lines.join("\n")}` }],
334
+ };
335
+ } catch (err: any) {
336
+ return {
337
+ content: [{ type: "text", text: `Error listing sessions: ${err.message}` }],
338
+ isError: true,
339
+ };
340
+ }
341
+ }
342
+
343
+ // ============================================================================
344
+ // Start server
345
+ // ============================================================================
346
+
347
+ async function main() {
348
+ const transport = new StdioServerTransport();
349
+ await server.connect(transport);
350
+ console.error("duncan-cc MCP server running on stdio");
351
+ }
352
+
353
+ main().catch((err) => {
354
+ console.error("Fatal:", err);
355
+ process.exit(1);
356
+ });