Skip to content

Commit eb310f3

Browse files
committed
Replaces agent-session quickpick with a smart dispatcher
- Routes session opens deterministically by host: extension-hosted (entrypoint=claude-vscode) opens in the Claude Code extension, CLI-hosted focuses the owning terminal window, out-of-workspace asks a peer GitLens window to pre-open the session then switches the folder - Adds an `agents/sessions/open` IPC route so a peer window can open a session in its Claude Code extension on behalf of the current window; caps the peer-notify wait at 500ms so an unhealthy peer can't stall the click - Reads `~/.claude/sessions/<pid>.json` for the `entrypoint` field to classify the host, with a stale-file guard (pid mismatch returns undefined) and a sensible fallback when the file is missing
1 parent 796c9ee commit eb310f3

9 files changed

Lines changed: 530 additions & 78 deletions

File tree

packages/plus/agents/src/__tests__/claudeCodeProvider.test.ts

Lines changed: 246 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,24 @@ import type { AgentProviderCallbacks, IpcRegistrar } from '../types.js';
88

99
interface MockCallbacks {
1010
callbacks: AgentProviderCallbacks;
11-
handlers: Map<string, IpcHandler>;
11+
handlers: Map<string, IpcHandler<unknown, unknown>>;
1212
publishedPaths: string[][];
1313
}
1414

15-
function createMockCallbacks(options?: { resolveGitInfo?: AgentProviderCallbacks['resolveGitInfo'] }): MockCallbacks {
16-
const handlers = new Map<string, IpcHandler>();
15+
function createMockCallbacks(options?: {
16+
resolveGitInfo?: AgentProviderCallbacks['resolveGitInfo'];
17+
openSessionInClaudeExtension?: AgentProviderCallbacks['openSessionInClaudeExtension'];
18+
port?: number;
19+
agentDiscoveryDir?: string;
20+
}): MockCallbacks {
21+
const handlers = new Map<string, IpcHandler<unknown, unknown>>();
1722
const publishedPaths: string[][] = [];
1823

1924
const ipc: IpcRegistrar = {
20-
port: 1234,
25+
port: options?.port ?? 1234,
26+
agentDiscoveryDir: options?.agentDiscoveryDir,
2127
registerHandler: <Request, Response>(name: string, handler: IpcHandler<Request, Response>) => {
22-
handlers.set(name, handler as unknown as IpcHandler);
28+
handlers.set(name, handler as unknown as IpcHandler<unknown, unknown>);
2329
return createDisposable(() => {
2430
handlers.delete(name);
2531
});
@@ -35,6 +41,7 @@ function createMockCallbacks(options?: { resolveGitInfo?: AgentProviderCallbacks
3541
ipc: ipc,
3642
runCLICommand: () => Promise.resolve('[]'),
3743
resolveGitInfo: options?.resolveGitInfo,
44+
openSessionInClaudeExtension: options?.openSessionInClaudeExtension,
3845
};
3946

4047
return { callbacks: callbacks, handlers: handlers, publishedPaths: publishedPaths };
@@ -503,6 +510,240 @@ suite('ClaudeCodeProvider', () => {
503510
}
504511
});
505512
});
513+
514+
suite('agents/sessions/open IPC handler', () => {
515+
test('invokes the host callback with the requested sessionId', async () => {
516+
const calls: string[] = [];
517+
const { callbacks, handlers } = createMockCallbacks({
518+
openSessionInClaudeExtension: sessionId => {
519+
calls.push(sessionId);
520+
return Promise.resolve();
521+
},
522+
});
523+
const provider = new ClaudeCodeProvider(callbacks);
524+
try {
525+
provider.start(['/repo']);
526+
await flushMicrotasks();
527+
528+
const handler = handlers.get('agents/sessions/open');
529+
assert.ok(handler != null, 'agents/sessions/open handler should be registered');
530+
531+
const response = await handler({ sessionId: 'sess-1' }, new URLSearchParams());
532+
assert.deepStrictEqual(calls, ['sess-1']);
533+
assert.deepStrictEqual(response, {});
534+
} finally {
535+
provider.dispose();
536+
}
537+
});
538+
539+
test('returns {} without invoking the callback when sessionId is missing', async () => {
540+
let called = false;
541+
const { callbacks, handlers } = createMockCallbacks({
542+
openSessionInClaudeExtension: () => {
543+
called = true;
544+
return Promise.resolve();
545+
},
546+
});
547+
const provider = new ClaudeCodeProvider(callbacks);
548+
try {
549+
provider.start(['/repo']);
550+
await flushMicrotasks();
551+
552+
const handler = handlers.get('agents/sessions/open')!;
553+
const response = await handler({}, new URLSearchParams());
554+
assert.strictEqual(called, false, 'callback must not run when sessionId is absent');
555+
assert.deepStrictEqual(response, {});
556+
} finally {
557+
provider.dispose();
558+
}
559+
});
560+
561+
test('returns {} when the host did not wire openSessionInClaudeExtension', async () => {
562+
const { callbacks, handlers } = createMockCallbacks();
563+
const provider = new ClaudeCodeProvider(callbacks);
564+
try {
565+
provider.start(['/repo']);
566+
await flushMicrotasks();
567+
568+
const handler = handlers.get('agents/sessions/open')!;
569+
const response = await handler({ sessionId: 'sess-1' }, new URLSearchParams());
570+
assert.deepStrictEqual(response, {});
571+
} finally {
572+
provider.dispose();
573+
}
574+
});
575+
576+
test('swallows callback errors so the peer never sees a 500', async () => {
577+
const { callbacks, handlers } = createMockCallbacks({
578+
openSessionInClaudeExtension: () => Promise.reject(new Error('extension not installed')),
579+
});
580+
const provider = new ClaudeCodeProvider(callbacks);
581+
try {
582+
provider.start(['/repo']);
583+
await flushMicrotasks();
584+
585+
const handler = handlers.get('agents/sessions/open')!;
586+
const response = await handler({ sessionId: 'sess-1' }, new URLSearchParams());
587+
assert.deepStrictEqual(response, {});
588+
} finally {
589+
provider.dispose();
590+
}
591+
});
592+
});
593+
594+
suite('notifyPeerOpenSession', () => {
595+
test("skips the discovery file matching this provider's own port", async () => {
596+
const { default: http } = await import('node:http');
597+
const { mkdtemp, rm, writeFile } = await import('node:fs/promises');
598+
const { tmpdir } = await import('node:os');
599+
const { join } = await import('node:path');
600+
601+
const dir = await mkdtemp(join(tmpdir(), 'gitlens-discovery-self-'));
602+
const hits: string[] = [];
603+
const server = http.createServer((req, res) => {
604+
hits.push(req.url ?? '');
605+
res.writeHead(200);
606+
res.end('{}');
607+
});
608+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
609+
const port = (server.address() as { port: number }).port;
610+
try {
611+
await writeFile(
612+
join(dir, 'gitlens-ipc-server-self.json'),
613+
JSON.stringify({
614+
token: 't',
615+
address: `http://127.0.0.1:${port}`,
616+
port: port,
617+
workspacePaths: ['/repo'],
618+
}),
619+
);
620+
621+
const { callbacks } = createMockCallbacks({ port: port, agentDiscoveryDir: dir });
622+
const provider = new ClaudeCodeProvider(callbacks);
623+
try {
624+
provider.start(['/repo']);
625+
await flushMicrotasks();
626+
hits.length = 0; // ignore any pre-existing list-route hits (there should be none)
627+
await provider.notifyPeerOpenSession('/repo', 'sess-1');
628+
assert.deepStrictEqual(
629+
hits.filter(u => u === '/agents/sessions/open'),
630+
[],
631+
'own-port discovery file must be skipped',
632+
);
633+
} finally {
634+
provider.dispose();
635+
}
636+
} finally {
637+
await new Promise<void>(resolve => server.close(() => resolve()));
638+
await rm(dir, { recursive: true, force: true });
639+
}
640+
});
641+
642+
test('skips peers whose workspacePaths do not include the target', async () => {
643+
const { default: http } = await import('node:http');
644+
const { mkdtemp, rm, writeFile } = await import('node:fs/promises');
645+
const { tmpdir } = await import('node:os');
646+
const { join } = await import('node:path');
647+
648+
const dir = await mkdtemp(join(tmpdir(), 'gitlens-discovery-mismatch-'));
649+
const hits: string[] = [];
650+
const server = http.createServer((req, res) => {
651+
hits.push(req.url ?? '');
652+
res.writeHead(200);
653+
res.end('{}');
654+
});
655+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
656+
const peerPort = (server.address() as { port: number }).port;
657+
try {
658+
await writeFile(
659+
join(dir, 'gitlens-ipc-server-other.json'),
660+
JSON.stringify({
661+
token: 't',
662+
address: `http://127.0.0.1:${peerPort}`,
663+
port: peerPort,
664+
workspacePaths: ['/other/workspace'],
665+
}),
666+
);
667+
668+
const { callbacks } = createMockCallbacks({ port: peerPort + 1, agentDiscoveryDir: dir });
669+
const provider = new ClaudeCodeProvider(callbacks);
670+
try {
671+
provider.start(['/repo']);
672+
await flushMicrotasks();
673+
hits.length = 0; // ignore `/agents/sessions/list` from querySiblingWindowSessions
674+
await provider.notifyPeerOpenSession('/repo', 'sess-1');
675+
assert.deepStrictEqual(
676+
hits.filter(u => u === '/agents/sessions/open'),
677+
[],
678+
'mismatched-workspace peer must not be POSTed',
679+
);
680+
} finally {
681+
provider.dispose();
682+
}
683+
} finally {
684+
await new Promise<void>(resolve => server.close(() => resolve()));
685+
await rm(dir, { recursive: true, force: true });
686+
}
687+
});
688+
689+
test('POSTs the sessionId to peers whose workspacePaths include the (normalized) target', async () => {
690+
const { default: http } = await import('node:http');
691+
const { mkdtemp, rm, writeFile } = await import('node:fs/promises');
692+
const { tmpdir } = await import('node:os');
693+
const { join } = await import('node:path');
694+
695+
const dir = await mkdtemp(join(tmpdir(), 'gitlens-discovery-match-'));
696+
const requests: { url: string; auth: string | undefined; body: string }[] = [];
697+
const server = http.createServer((req, res) => {
698+
const chunks: Buffer[] = [];
699+
req.on('data', c => chunks.push(c as Buffer));
700+
req.on('end', () => {
701+
requests.push({
702+
url: req.url ?? '',
703+
auth: req.headers['authorization'],
704+
body: Buffer.concat(chunks).toString('utf8'),
705+
});
706+
res.writeHead(200);
707+
res.end('{}');
708+
});
709+
});
710+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
711+
const peerPort = (server.address() as { port: number }).port;
712+
try {
713+
await writeFile(
714+
join(dir, 'gitlens-ipc-server-peer.json'),
715+
JSON.stringify({
716+
token: 'peer-token',
717+
address: `http://127.0.0.1:${peerPort}`,
718+
port: peerPort,
719+
// Mixed-separator path on purpose — `notifyPeerOpenSession` normalizes both sides.
720+
workspacePaths: ['d:\\PROJ\\GKGL\\vscode-gitlens'],
721+
}),
722+
);
723+
724+
const { callbacks } = createMockCallbacks({ port: peerPort + 1, agentDiscoveryDir: dir });
725+
const provider = new ClaudeCodeProvider(callbacks);
726+
try {
727+
provider.start(['/somewhere/else']);
728+
await flushMicrotasks();
729+
// Ignore the unrelated `/agents/sessions/list` POST that `querySiblingWindowSessions`
730+
// fires on start — we only care about what `notifyPeerOpenSession` does.
731+
requests.length = 0;
732+
await provider.notifyPeerOpenSession('d:/PROJ/GKGL/vscode-gitlens', 'sess-42');
733+
734+
const openRequests = requests.filter(r => r.url === '/agents/sessions/open');
735+
assert.strictEqual(openRequests.length, 1, 'matching peer should receive exactly one open POST');
736+
assert.strictEqual(openRequests[0].auth, 'Bearer peer-token');
737+
assert.deepStrictEqual(JSON.parse(openRequests[0].body), { sessionId: 'sess-42' });
738+
} finally {
739+
provider.dispose();
740+
}
741+
} finally {
742+
await new Promise<void>(resolve => server.close(() => resolve()));
743+
await rm(dir, { recursive: true, force: true });
744+
}
745+
});
746+
});
506747
});
507748

508749
/** Drop-in transcript reader for provider tests — records calls and returns canned titles. */

packages/plus/agents/src/providers/claudeCodeProvider.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,20 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
320320
})),
321321
),
322322
),
323+
this.callbacks.ipc.registerHandler('agents/sessions/open', async request => {
324+
const sessionId = (request as { sessionId?: string } | undefined)?.sessionId;
325+
if (!sessionId || this.callbacks.openSessionInClaudeExtension == null) return {};
326+
327+
try {
328+
await this.callbacks.openSessionInClaudeExtension(sessionId);
329+
} catch (ex) {
330+
Logger.warn(
331+
`ClaudeCodeProvider.agents/sessions/open: ${ex instanceof Error ? ex.message : String(ex)}`,
332+
);
333+
}
334+
335+
return {};
336+
}),
323337
];
324338

325339
try {
@@ -1513,4 +1527,53 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
15131527
return undefined;
15141528
}
15151529
}
1530+
1531+
/** Find the peer GitLens window whose discovery file claims `workspacePath`, and POST to its
1532+
* `/agents/sessions/open` route to ask its Claude Code extension to open the session. The
1533+
* caller is responsible for the follow-up `vscode.openFolder` that focuses the peer window —
1534+
* this method just makes sure the session is ready in the peer's editor before the focus
1535+
* switch lands. Best-effort: swallows scan, network, and JSON-parse errors. */
1536+
async notifyPeerOpenSession(workspacePath: string, sessionId: string): Promise<void> {
1537+
const discoveryDir = this.callbacks.ipc.agentDiscoveryDir;
1538+
if (discoveryDir == null) return;
1539+
1540+
const target = normalizePath(workspacePath);
1541+
const ownPort = this.callbacks.ipc.port;
1542+
1543+
let files: string[];
1544+
try {
1545+
files = await readdir(discoveryDir);
1546+
} catch {
1547+
return;
1548+
}
1549+
1550+
await Promise.all(
1551+
files
1552+
.filter(f => f.startsWith('gitlens-ipc-server-') && f.endsWith('.json'))
1553+
.map(async f => {
1554+
let discovery: DiscoveryFile;
1555+
try {
1556+
discovery = JSON.parse(await readFile(join(discoveryDir, f), 'utf8')) as DiscoveryFile;
1557+
} catch {
1558+
return;
1559+
}
1560+
if (ownPort != null && discovery.port === ownPort) return;
1561+
if (!discovery.workspacePaths?.some(p => normalizePath(p) === target)) return;
1562+
1563+
try {
1564+
await fetch(`${discovery.address}/agents/sessions/open`, {
1565+
method: 'POST',
1566+
headers: {
1567+
Authorization: `Bearer ${discovery.token}`,
1568+
'Content-Type': 'application/json',
1569+
},
1570+
body: JSON.stringify({ sessionId: sessionId }),
1571+
signal: AbortSignal.timeout(2000),
1572+
});
1573+
} catch {
1574+
// Best-effort — vscode.openFolder still focuses the peer window.
1575+
}
1576+
}),
1577+
);
1578+
}
15161579
}

packages/plus/agents/src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,12 @@ export interface AgentSessionProvider extends UnifiedDisposable {
180180
decision: PermissionDecision,
181181
updatedPermissions?: PermissionSuggestion[],
182182
): boolean;
183+
184+
/** Asks the peer GitLens window that has `workspacePath` open to open the given session in its
185+
* Claude Code extension (via the `agents/sessions/open` IPC route). Best-effort: resolves
186+
* silently if no peer claims the workspace or the POST fails. Callers should follow up with
187+
* `vscode.openFolder` so VS Code focuses that peer window. */
188+
notifyPeerOpenSession?(workspacePath: string, sessionId: string): Promise<void>;
183189
}
184190

185191
/**
@@ -243,4 +249,11 @@ export interface AgentProviderCallbacks {
243249
}
244250
| undefined
245251
>;
252+
253+
/**
254+
* Open a Claude Code session in the Claude Code VS Code extension. Invoked by the IPC handler
255+
* when a peer GitLens window asks this window to open a session on its behalf — the host wires
256+
* this to `claude-vscode.editor.open`. Throws if the extension isn't installed/active.
257+
*/
258+
openSessionInClaudeExtension?(sessionId: string): Promise<void>;
246259
}

0 commit comments

Comments
 (0)