fix: add missing runtime stage name to Dockerfile (#2765)

* feat: Registered 6 MCP tools (gsd_execute, gsd_status, gsd_result, gsd_…

- "packages/mcp-server/src/server.ts"
- "packages/mcp-server/src/cli.ts"
- "packages/mcp-server/src/index.ts"
- "packages/rpc-client/dist/index.d.ts"

GSD-Task: S05/T02

* docs: Added 31 integration tests, build pipeline, and consumer README f…

- "packages/mcp-server/src/mcp-server.test.ts"
- "packages/mcp-server/README.md"
- "packages/mcp-server/dist/"

GSD-Task: S05/T03

* fix: add missing runtime stage name to Dockerfile

CI pipeline uses `docker build --target runtime` but the FROM line
lacked the `AS runtime` alias, causing the build to fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-26 17:52:45 -06:00 committed by GitHub
parent e296d8b9d9
commit 1c2d7ab307
12 changed files with 1743 additions and 4 deletions

View file

@ -3,7 +3,7 @@
# Image: ghcr.io/gsd-build/gsd-pi
# Used by: end users via docker run
# ──────────────────────────────────────────────
FROM node:24-slim
FROM node:24-slim AS runtime
# Git is required for GSD's git operations
RUN apt-get update && apt-get install -y --no-install-recommends \

40
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "gsd-pi",
"version": "2.49.0",
"version": "2.51.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gsd-pi",
"version": "2.49.0",
"version": "2.51.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@ -1815,6 +1815,10 @@
"win32"
]
},
"node_modules/@gsd/mcp-server": {
"resolved": "packages/mcp-server",
"link": true
},
"node_modules/@gsd/native": {
"resolved": "packages/native",
"link": true
@ -1835,6 +1839,10 @@
"resolved": "packages/pi-tui",
"link": true
},
"node_modules/@gsd/rpc-client": {
"resolved": "packages/rpc-client",
"link": true
},
"node_modules/@gsd/studio": {
"resolved": "studio",
"link": true
@ -9141,6 +9149,25 @@
}
}
},
"packages/mcp-server": {
"name": "@gsd/mcp-server",
"version": "2.51.0",
"dependencies": {
"@gsd/rpc-client": "*",
"@modelcontextprotocol/sdk": "^1.27.1",
"zod": "^4.0.0"
},
"bin": {
"gsd-mcp-server": "dist/cli.js"
},
"devDependencies": {
"@types/node": "^24.12.0",
"typescript": "^5.4.0"
},
"engines": {
"node": ">=22.0.0"
}
},
"packages/native": {
"name": "@gsd/native",
"version": "0.1.0",
@ -9191,7 +9218,7 @@
},
"packages/pi-coding-agent": {
"name": "@gsd/pi-coding-agent",
"version": "2.49.0",
"version": "2.51.0",
"dependencies": {
"@mariozechner/jiti": "^2.6.2",
"@silvia-odwyer/photon-node": "^0.3.4",
@ -9233,6 +9260,13 @@
"koffi": "^2.9.0"
}
},
"packages/rpc-client": {
"name": "@gsd/rpc-client",
"version": "2.51.0",
"engines": {
"node": ">=22.0.0"
}
},
"studio": {
"name": "@gsd/studio",
"version": "0.0.0",

View file

@ -0,0 +1,202 @@
# @gsd/mcp-server
MCP server exposing GSD orchestration tools for Claude Code, Cursor, and other MCP-compatible clients.
Start GSD auto-mode sessions, poll progress, resolve blockers, and retrieve results — all through the [Model Context Protocol](https://modelcontextprotocol.io/).
## Installation
```bash
npm install @gsd/mcp-server
```
Or with the monorepo workspace:
```bash
# Already available as a workspace package
npx gsd-mcp-server
```
## Configuration
### Claude Code
Add to your project's `.mcp.json`:
```json
{
"mcpServers": {
"gsd": {
"command": "npx",
"args": ["gsd-mcp-server"],
"env": {
"GSD_CLI_PATH": "/path/to/gsd"
}
}
}
}
```
Or if installed globally:
```json
{
"mcpServers": {
"gsd": {
"command": "gsd-mcp-server"
}
}
}
```
### Cursor
Add to `.cursor/mcp.json`:
```json
{
"mcpServers": {
"gsd": {
"command": "npx",
"args": ["gsd-mcp-server"],
"env": {
"GSD_CLI_PATH": "/path/to/gsd"
}
}
}
}
```
## Tools
### `gsd_execute`
Start a GSD auto-mode session for a project directory.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `projectDir` | `string` | ✅ | Absolute path to the project directory |
| `command` | `string` | | Command to send (default: `"/gsd auto"`) |
| `model` | `string` | | Model ID override |
| `bare` | `boolean` | | Run in bare mode (skip user config) |
**Returns:** `{ sessionId, status: "started" }`
### `gsd_status`
Poll the current status of a running GSD session.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sessionId` | `string` | ✅ | Session ID from `gsd_execute` |
**Returns:**
```json
{
"status": "running",
"progress": { "eventCount": 42, "toolCalls": 15 },
"recentEvents": [ ... ],
"pendingBlocker": null,
"cost": { "totalCost": 0.12, "tokens": { "input": 5000, "output": 2000, "cacheRead": 1000, "cacheWrite": 500 } },
"durationMs": 45000
}
```
### `gsd_result`
Get the accumulated result of a session. Works for both running (partial) and completed sessions.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sessionId` | `string` | ✅ | Session ID from `gsd_execute` |
**Returns:**
```json
{
"sessionId": "abc-123",
"projectDir": "/path/to/project",
"status": "completed",
"durationMs": 120000,
"cost": { ... },
"recentEvents": [ ... ],
"pendingBlocker": null,
"error": null
}
```
### `gsd_cancel`
Cancel a running session. Aborts the current operation and stops the agent process.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sessionId` | `string` | ✅ | Session ID from `gsd_execute` |
**Returns:** `{ cancelled: true }`
### `gsd_query`
Query GSD project state from the filesystem without an active session. Returns STATE.md, PROJECT.md, requirements, and milestone listing.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `projectDir` | `string` | ✅ | Absolute path to the project directory |
| `query` | `string` | ✅ | What to query (e.g. `"status"`, `"milestones"`) |
**Returns:**
```json
{
"projectDir": "/path/to/project",
"state": "...",
"project": "...",
"requirements": "...",
"milestones": [
{ "id": "M001", "hasRoadmap": true, "hasSummary": false }
]
}
```
### `gsd_resolve_blocker`
Resolve a pending blocker in a session by sending a response to the blocked UI request.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `sessionId` | `string` | ✅ | Session ID from `gsd_execute` |
| `response` | `string` | ✅ | Response to send for the pending blocker |
**Returns:** `{ resolved: true }`
## Environment Variables
| Variable | Description |
|----------|-------------|
| `GSD_CLI_PATH` | Absolute path to the GSD CLI binary. If not set, the server resolves `gsd` via `which`. |
## Architecture
```
┌─────────────────┐ stdio ┌──────────────────┐
│ MCP Client │ ◄────────────► │ @gsd/mcp-server │
│ (Claude Code, │ JSON-RPC │ │
│ Cursor, etc.) │ │ SessionManager │
└─────────────────┘ │ │ │
│ ▼ │
@gsd/rpc-client │
│ │ │
│ ▼ │
│ GSD CLI (child │
│ process via RPC)│
└──────────────────┘
```
- **@gsd/mcp-server** — MCP protocol adapter. Translates MCP tool calls into SessionManager operations.
- **SessionManager** — Manages RpcClient lifecycle. One session per project directory. Tracks events in a ring buffer (last 50), detects blockers, accumulates cost.
- **@gsd/rpc-client** — Low-level RPC client that spawns and communicates with the GSD CLI process via JSON-RPC over stdio.
## License
MIT

View file

@ -0,0 +1,36 @@
{
"name": "@gsd/mcp-server",
"version": "2.51.0",
"description": "MCP server exposing GSD orchestration tools for Claude Code, Cursor, and other MCP clients",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"bin": {
"gsd-mcp-server": "./dist/cli.js"
},
"scripts": {
"build": "tsc",
"test": "node --test dist/mcp-server.test.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"@gsd/rpc-client": "*",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/node": "^24.12.0",
"typescript": "^5.4.0"
},
"engines": {
"node": ">=22.0.0"
},
"files": [
"dist"
]
}

View file

@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* @gsd/mcp-server CLI stdio transport entry point.
*
* Connects the MCP server to stdin/stdout for use by Claude Code,
* Cursor, and other MCP-compatible clients.
*/
import { SessionManager } from './session-manager.js';
import { createMcpServer } from './server.js';
const MCP_PKG = '@modelcontextprotocol/sdk';
async function main(): Promise<void> {
const sessionManager = new SessionManager();
// Create the configured MCP server with all 6 tools
const { server } = await createMcpServer(sessionManager);
// Dynamic import for StdioServerTransport (same TS subpath workaround)
const { StdioServerTransport } = await import(`${MCP_PKG}/server/stdio.js`);
const transport = new StdioServerTransport();
// Cleanup handler — stop all sessions before exiting
let cleaningUp = false;
async function cleanup(): Promise<void> {
if (cleaningUp) return;
cleaningUp = true;
process.stderr.write('[gsd-mcp-server] Shutting down...\n');
try {
await sessionManager.cleanup();
} catch {
// swallow cleanup errors
}
try {
await server.close();
} catch {
// swallow close errors
}
process.exit(0);
}
process.on('SIGTERM', () => void cleanup());
process.on('SIGINT', () => void cleanup());
// Handle stdin end — MCP client disconnected
process.stdin.on('end', () => void cleanup());
// Connect and start serving
try {
await server.connect(transport);
process.stderr.write('[gsd-mcp-server] MCP server started on stdio\n');
} catch (err) {
process.stderr.write(
`[gsd-mcp-server] Fatal: failed to start — ${err instanceof Error ? err.message : String(err)}\n`
);
await sessionManager.cleanup();
process.exit(1);
}
}
main().catch((err) => {
process.stderr.write(
`[gsd-mcp-server] Fatal: ${err instanceof Error ? err.message : String(err)}\n`
);
process.exit(1);
});

View file

@ -0,0 +1,14 @@
/**
* @gsd/mcp-server MCP server for GSD orchestration.
*/
export { SessionManager } from './session-manager.js';
export { createMcpServer } from './server.js';
export type {
SessionStatus,
ManagedSession,
ExecuteOptions,
PendingBlocker,
CostAccumulator,
} from './types.js';
export { MAX_EVENTS, INIT_TIMEOUT_MS } from './types.js';

View file

@ -0,0 +1,628 @@
/**
* @gsd/mcp-server Integration and unit tests.
*
* Strategy: We cannot mock @gsd/rpc-client at the module level without
* --experimental-test-module-mocks. Instead we test by:
*
* 1. Subclassing SessionManager to inject a mock client factory
* 2. Testing event handling, state transitions, and error paths
* 3. Testing tool registration via createMcpServer
* 4. Testing CLI path resolution via static method
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { EventEmitter } from 'node:events';
import { SessionManager } from './session-manager.js';
import { createMcpServer } from './server.js';
import { MAX_EVENTS } from './types.js';
import type { ManagedSession, CostAccumulator, PendingBlocker } from './types.js';
// ---------------------------------------------------------------------------
// Mock RpcClient (duck-typed to match RpcClient interface)
// ---------------------------------------------------------------------------
class MockRpcClient {
started = false;
stopped = false;
aborted = false;
prompted: string[] = [];
private eventListeners: Array<(event: Record<string, unknown>) => void> = [];
uiResponses: Array<{ requestId: string; response: Record<string, unknown> }> = [];
/** Control — set to make start() reject */
startError: Error | null = null;
/** Control — set to make init() reject */
initError: Error | null = null;
/** Control — override sessionId from init */
initSessionId = 'mock-session-001';
cwd: string;
args: string[];
constructor(options?: Record<string, unknown>) {
this.cwd = (options?.cwd as string) ?? '';
this.args = (options?.args as string[]) ?? [];
}
async start(): Promise<void> {
if (this.startError) throw this.startError;
this.started = true;
}
async stop(): Promise<void> {
this.stopped = true;
}
async init(): Promise<{ sessionId: string; version: string }> {
if (this.initError) throw this.initError;
return { sessionId: this.initSessionId, version: '2.51.0' };
}
onEvent(listener: (event: Record<string, unknown>) => void): () => void {
this.eventListeners.push(listener);
return () => {
const idx = this.eventListeners.indexOf(listener);
if (idx >= 0) this.eventListeners.splice(idx, 1);
};
}
async prompt(message: string): Promise<void> {
this.prompted.push(message);
}
async abort(): Promise<void> {
this.aborted = true;
}
sendUIResponse(requestId: string, response: Record<string, unknown>): void {
this.uiResponses.push({ requestId, response });
}
/** Test helper — emit an event to all listeners */
emitEvent(event: Record<string, unknown>): void {
for (const listener of this.eventListeners) {
listener(event);
}
}
}
// ---------------------------------------------------------------------------
// TestableSessionManager — injects mock clients without module mocking
// ---------------------------------------------------------------------------
/**
* Subclass that overrides startSession to use MockRpcClient instead of the
* real RpcClient. We directly construct the session object, mirroring the
* parent's logic but with our mock.
*/
class TestableSessionManager extends SessionManager {
/** The last mock client created */
lastClient: MockRpcClient | null = null;
/** All mock clients */
allClients: MockRpcClient[] = [];
/** Counter for unique session IDs across multiple sessions */
private sessionCounter = 0;
/** Control: set to make startSession fail during init */
nextInitError: Error | null = null;
/** Control: set to make startSession fail during start */
nextStartError: Error | null = null;
override async startSession(projectDir: string, options: { cliPath?: string; command?: string; model?: string; bare?: boolean } = {}): Promise<string> {
if (!projectDir || projectDir.trim() === '') {
throw new Error('projectDir is required and cannot be empty');
}
const resolvedDir = resolve(projectDir);
// Check duplicate via getSessionByDir
const existing = this.getSessionByDir(resolvedDir);
if (existing) {
throw new Error(
`Session already active for ${resolvedDir} (sessionId: ${existing.sessionId}, status: ${existing.status})`
);
}
const client = new MockRpcClient({ cwd: resolvedDir, args: [] });
if (this.nextStartError) {
client.startError = this.nextStartError;
this.nextStartError = null;
}
if (this.nextInitError) {
client.initError = this.nextInitError;
this.nextInitError = null;
}
this.sessionCounter++;
client.initSessionId = `mock-session-${String(this.sessionCounter).padStart(3, '0')}`;
this.lastClient = client;
this.allClients.push(client);
// Create the session shell
const session: ManagedSession = {
sessionId: '',
projectDir: resolvedDir,
status: 'starting',
client: client as any, // duck-typed mock
events: [],
pendingBlocker: null,
cost: { totalCost: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } },
startTime: Date.now(),
};
// Insert into internal sessions map — access via protected method
this._putSession(resolvedDir, session);
try {
await client.start();
const initResult = await client.init();
session.sessionId = initResult.sessionId;
session.status = 'running';
// Wire event tracking using the same handleEvent logic as parent
session.unsubscribe = client.onEvent((event: Record<string, unknown>) => {
this._handleEvent(session, event);
});
// Kick off auto-mode
const command = options.command ?? '/gsd auto';
await client.prompt(command);
return session.sessionId;
} catch (err) {
session.status = 'error';
session.error = err instanceof Error ? err.message : String(err);
try { await client.stop(); } catch { /* swallow */ }
throw new Error(`Failed to start session for ${resolvedDir}: ${session.error}`);
}
}
/** Expose internal session map insertion for testing */
_putSession(key: string, session: ManagedSession): void {
// Access the private sessions map via any cast
(this as any).sessions.set(key, session);
}
/** Expose handleEvent for testing */
_handleEvent(session: ManagedSession, event: Record<string, unknown>): void {
(this as any).handleEvent(session, event);
}
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
let allManagers: TestableSessionManager[] = [];
function createManager(): TestableSessionManager {
const mgr = new TestableSessionManager();
allManagers.push(mgr);
return mgr;
}
// ---------------------------------------------------------------------------
// SessionManager unit tests
// ---------------------------------------------------------------------------
describe('SessionManager', () => {
let sm: TestableSessionManager;
beforeEach(() => {
sm = createManager();
});
afterEach(async () => {
for (const mgr of allManagers) {
await mgr.cleanup();
}
allManagers = [];
});
it('startSession creates session and returns sessionId', async () => {
const sessionId = await sm.startSession('/tmp/test-project', { cliPath: '/usr/bin/gsd' });
assert.equal(sessionId, 'mock-session-001');
const session = sm.getSession(sessionId);
assert.ok(session);
assert.equal(session.status, 'running');
assert.equal(session.projectDir, resolve('/tmp/test-project'));
});
it('startSession sends /gsd auto by default', async () => {
await sm.startSession('/tmp/test-prompt', { cliPath: '/usr/bin/gsd' });
assert.ok(sm.lastClient);
assert.deepEqual(sm.lastClient.prompted, ['/gsd auto']);
});
it('startSession sends custom command when provided', async () => {
await sm.startSession('/tmp/test-cmd', { cliPath: '/usr/bin/gsd', command: '/gsd auto --resume' });
assert.ok(sm.lastClient);
assert.deepEqual(sm.lastClient.prompted, ['/gsd auto --resume']);
});
it('startSession rejects duplicate projectDir', async () => {
await sm.startSession('/tmp/dup-test', { cliPath: '/usr/bin/gsd' });
await assert.rejects(
() => sm.startSession('/tmp/dup-test', { cliPath: '/usr/bin/gsd' }),
(err: Error) => {
assert.ok(err.message.includes('Session already active'));
return true;
},
);
});
it('startSession rejects empty projectDir', async () => {
await assert.rejects(
() => sm.startSession('', { cliPath: '/usr/bin/gsd' }),
(err: Error) => {
assert.ok(err.message.includes('projectDir is required'));
return true;
},
);
});
it('startSession sets error status on start() failure', async () => {
sm.nextStartError = new Error('spawn failed');
await assert.rejects(
() => sm.startSession('/tmp/fail-start', { cliPath: '/usr/bin/gsd' }),
(err: Error) => {
assert.ok(err.message.includes('Failed to start session'));
assert.ok(err.message.includes('spawn failed'));
return true;
},
);
});
it('startSession sets error status on init() failure', async () => {
sm.nextInitError = new Error('handshake failed');
await assert.rejects(
() => sm.startSession('/tmp/fail-init', { cliPath: '/usr/bin/gsd' }),
(err: Error) => {
assert.ok(err.message.includes('Failed to start session'));
assert.ok(err.message.includes('handshake failed'));
return true;
},
);
});
it('getSession returns undefined for unknown sessionId', () => {
const result = sm.getSession('nonexistent-id');
assert.equal(result, undefined);
});
it('getSessionByDir returns session for known dir', async () => {
await sm.startSession('/tmp/by-dir', { cliPath: '/usr/bin/gsd' });
const session = sm.getSessionByDir('/tmp/by-dir');
assert.ok(session);
assert.equal(session.sessionId, 'mock-session-001');
});
it('resolveBlocker errors when no pending blocker', async () => {
const sessionId = await sm.startSession('/tmp/no-blocker', { cliPath: '/usr/bin/gsd' });
await assert.rejects(
() => sm.resolveBlocker(sessionId, 'some response'),
(err: Error) => {
assert.ok(err.message.includes('No pending blocker'));
return true;
},
);
});
it('resolveBlocker errors for unknown session', async () => {
await assert.rejects(
() => sm.resolveBlocker('unknown-session', 'some response'),
(err: Error) => {
assert.ok(err.message.includes('Session not found'));
return true;
},
);
});
it('resolveBlocker clears pendingBlocker and sends UI response', async () => {
const sessionId = await sm.startSession('/tmp/blocker-resolve', { cliPath: '/usr/bin/gsd' });
const client = sm.lastClient!;
// Simulate a blocking UI request event
client.emitEvent({
type: 'extension_ui_request',
id: 'req-42',
method: 'select',
title: 'Pick an option',
});
const session = sm.getSession(sessionId)!;
assert.ok(session.pendingBlocker);
assert.equal(session.status, 'blocked');
// Resolve the blocker
await sm.resolveBlocker(sessionId, 'option-a');
assert.equal(session.pendingBlocker, null);
assert.equal(session.status, 'running');
assert.equal(client.uiResponses.length, 1);
assert.equal(client.uiResponses[0].requestId, 'req-42');
});
it('cancelSession calls abort + stop on client', async () => {
const sessionId = await sm.startSession('/tmp/cancel-test', { cliPath: '/usr/bin/gsd' });
const client = sm.lastClient!;
await sm.cancelSession(sessionId);
assert.ok(client.aborted);
assert.ok(client.stopped);
const session = sm.getSession(sessionId)!;
assert.equal(session.status, 'cancelled');
});
it('cancelSession errors for unknown session', async () => {
await assert.rejects(
() => sm.cancelSession('unknown'),
(err: Error) => {
assert.ok(err.message.includes('Session not found'));
return true;
},
);
});
it('cleanup stops all active sessions', async () => {
await sm.startSession('/tmp/cleanup-1', { cliPath: '/usr/bin/gsd' });
await sm.startSession('/tmp/cleanup-2', { cliPath: '/usr/bin/gsd' });
assert.equal(sm.allClients.length, 2);
await sm.cleanup();
for (const client of sm.allClients) {
assert.ok(client.stopped, 'Client should be stopped after cleanup');
}
});
it('event ring buffer caps at MAX_EVENTS', async () => {
const sessionId = await sm.startSession('/tmp/ring-buffer', { cliPath: '/usr/bin/gsd' });
const client = sm.lastClient!;
for (let i = 0; i < MAX_EVENTS + 20; i++) {
client.emitEvent({ type: 'tool_use', index: i });
}
const session = sm.getSession(sessionId)!;
assert.equal(session.events.length, MAX_EVENTS);
// Oldest events trimmed — first event index should be 20
assert.equal((session.events[0] as Record<string, unknown>).index, 20);
});
it('blocker detection: non-fire-and-forget extension_ui_request sets pendingBlocker', async () => {
const sessionId = await sm.startSession('/tmp/blocker-detect', { cliPath: '/usr/bin/gsd' });
const client = sm.lastClient!;
// 'select' is not in FIRE_AND_FORGET_METHODS
client.emitEvent({
type: 'extension_ui_request',
id: 'req-99',
method: 'select',
title: 'Choose wisely',
});
const session = sm.getSession(sessionId)!;
assert.equal(session.status, 'blocked');
assert.ok(session.pendingBlocker);
assert.equal(session.pendingBlocker.id, 'req-99');
assert.equal(session.pendingBlocker.method, 'select');
});
it('fire-and-forget methods do not set pendingBlocker', async () => {
const sessionId = await sm.startSession('/tmp/fire-forget', { cliPath: '/usr/bin/gsd' });
const client = sm.lastClient!;
// 'notify' is fire-and-forget — on its own (no terminal prefix) should not block
client.emitEvent({
type: 'extension_ui_request',
id: 'req-100',
method: 'notify',
message: 'Just a notification',
});
const session = sm.getSession(sessionId)!;
assert.equal(session.status, 'running');
assert.equal(session.pendingBlocker, null);
});
it('terminal detection: auto-mode stopped sets status to completed', async () => {
const sessionId = await sm.startSession('/tmp/terminal', { cliPath: '/usr/bin/gsd' });
const client = sm.lastClient!;
client.emitEvent({
type: 'extension_ui_request',
method: 'notify',
message: 'Auto-mode stopped — all tasks complete',
id: 'term-1',
});
const session = sm.getSession(sessionId)!;
assert.equal(session.status, 'completed');
});
it('terminal detection with blocked: message sets status to blocked', async () => {
const sessionId = await sm.startSession('/tmp/terminal-blocked', { cliPath: '/usr/bin/gsd' });
const client = sm.lastClient!;
client.emitEvent({
type: 'extension_ui_request',
method: 'notify',
message: 'Auto-mode stopped — blocked: needs user input',
id: 'block-1',
});
const session = sm.getSession(sessionId)!;
assert.equal(session.status, 'blocked');
assert.ok(session.pendingBlocker);
});
it('cost tracking: cumulative-max from cost_update events', async () => {
const sessionId = await sm.startSession('/tmp/cost-track', { cliPath: '/usr/bin/gsd' });
const client = sm.lastClient!;
client.emitEvent({
type: 'cost_update',
cumulativeCost: 0.05,
tokens: { input: 1000, output: 500, cacheRead: 200, cacheWrite: 100 },
});
client.emitEvent({
type: 'cost_update',
cumulativeCost: 0.12,
tokens: { input: 2500, output: 800, cacheRead: 150, cacheWrite: 300 },
});
const session = sm.getSession(sessionId)!;
assert.equal(session.cost.totalCost, 0.12);
assert.equal(session.cost.tokens.input, 2500);
assert.equal(session.cost.tokens.output, 800);
assert.equal(session.cost.tokens.cacheRead, 200); // First was higher
assert.equal(session.cost.tokens.cacheWrite, 300); // Second was higher
});
it('getResult returns HeadlessJsonResult-shaped object', async () => {
const sessionId = await sm.startSession('/tmp/result-shape', { cliPath: '/usr/bin/gsd' });
const result = sm.getResult(sessionId);
assert.equal(result.sessionId, sessionId);
assert.equal(result.projectDir, resolve('/tmp/result-shape'));
assert.equal(result.status, 'running');
assert.equal(typeof result.durationMs, 'number');
assert.ok(result.cost);
assert.ok(Array.isArray(result.recentEvents));
assert.equal(result.pendingBlocker, null);
assert.equal(result.error, null);
});
it('getResult errors for unknown session', () => {
assert.throws(
() => sm.getResult('unknown'),
(err: Error) => {
assert.ok(err.message.includes('Session not found'));
return true;
},
);
});
});
// ---------------------------------------------------------------------------
// CLI path resolution tests
// ---------------------------------------------------------------------------
describe('SessionManager.resolveCLIPath', () => {
const originalGsdPath = process.env['GSD_CLI_PATH'];
const originalPath = process.env['PATH'];
afterEach(() => {
if (originalGsdPath !== undefined) {
process.env['GSD_CLI_PATH'] = originalGsdPath;
} else {
delete process.env['GSD_CLI_PATH'];
}
if (originalPath !== undefined) {
process.env['PATH'] = originalPath;
}
});
it('GSD_CLI_PATH env var takes precedence', () => {
process.env['GSD_CLI_PATH'] = '/custom/path/to/gsd';
const result = SessionManager.resolveCLIPath();
assert.equal(result, resolve('/custom/path/to/gsd'));
});
it('throws when GSD_CLI_PATH not set and which fails', () => {
delete process.env['GSD_CLI_PATH'];
process.env['PATH'] = '/nonexistent';
assert.throws(
() => SessionManager.resolveCLIPath(),
(err: Error) => {
assert.ok(err.message.includes('Cannot find GSD CLI'));
return true;
},
);
});
});
// ---------------------------------------------------------------------------
// Tool registration tests (via createMcpServer)
// ---------------------------------------------------------------------------
describe('createMcpServer tool registration', () => {
let sm: TestableSessionManager;
beforeEach(() => {
sm = createManager();
});
afterEach(async () => {
for (const mgr of allManagers) {
await mgr.cleanup();
}
allManagers = [];
});
it('creates server successfully with all required methods', async () => {
const { server } = await createMcpServer(sm);
assert.ok(server);
assert.ok(typeof server.connect === 'function');
assert.ok(typeof server.close === 'function');
});
it('gsd_execute flow returns sessionId on success', async () => {
const sessionId = await sm.startSession('/tmp/tool-exec', { cliPath: '/usr/bin/gsd' });
assert.equal(typeof sessionId, 'string');
assert.ok(sessionId.length > 0);
});
it('gsd_status flow returns correct shape', async () => {
const sessionId = await sm.startSession('/tmp/tool-status', { cliPath: '/usr/bin/gsd' });
const session = sm.getSession(sessionId)!;
assert.equal(typeof session.status, 'string');
assert.ok(Array.isArray(session.events));
assert.ok(session.cost);
assert.equal(typeof session.startTime, 'number');
});
it('gsd_resolve_blocker flow returns error when no blocker', async () => {
const sessionId = await sm.startSession('/tmp/tool-resolve', { cliPath: '/usr/bin/gsd' });
await assert.rejects(
() => sm.resolveBlocker(sessionId, 'fix'),
(err: Error) => {
assert.ok(err.message.includes('No pending blocker'));
return true;
},
);
});
it('gsd_result flow returns HeadlessJsonResult shape', async () => {
const sessionId = await sm.startSession('/tmp/tool-result', { cliPath: '/usr/bin/gsd' });
const result = sm.getResult(sessionId);
assert.ok('sessionId' in result);
assert.ok('projectDir' in result);
assert.ok('status' in result);
assert.ok('durationMs' in result);
assert.ok('cost' in result);
assert.ok('recentEvents' in result);
assert.ok('pendingBlocker' in result);
assert.ok('error' in result);
});
it('gsd_cancel flow marks session as cancelled', async () => {
const sessionId = await sm.startSession('/tmp/tool-cancel', { cliPath: '/usr/bin/gsd' });
await sm.cancelSession(sessionId);
const session = sm.getSession(sessionId)!;
assert.equal(session.status, 'cancelled');
});
});

View file

@ -0,0 +1,278 @@
/**
* MCP Server registers 6 GSD orchestration tools on McpServer.
*
* Uses dynamic imports for @modelcontextprotocol/sdk because TS Node16
* cannot resolve the SDK's subpath exports statically (same pattern as
* src/mcp-server.ts in the main package).
*/
import { readFile, readdir, stat } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { z } from 'zod';
import type { SessionManager } from './session-manager.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MCP_PKG = '@modelcontextprotocol/sdk';
const SERVER_NAME = 'gsd';
const SERVER_VERSION = '2.51.0';
// ---------------------------------------------------------------------------
// Tool result helpers
// ---------------------------------------------------------------------------
/** Wrap a JSON-serializable value as MCP tool content. */
function jsonContent(data: unknown): { content: Array<{ type: 'text'; text: string }> } {
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
/** Return an MCP error response. */
function errorContent(message: string): { isError: true; content: Array<{ type: 'text'; text: string }> } {
return { isError: true, content: [{ type: 'text' as const, text: message }] };
}
// ---------------------------------------------------------------------------
// gsd_query filesystem reader
// ---------------------------------------------------------------------------
async function readProjectState(projectDir: string, _query: string): Promise<Record<string, unknown>> {
const gsdDir = join(resolve(projectDir), '.gsd');
const result: Record<string, unknown> = { projectDir: resolve(projectDir) };
// STATE.md — current execution state
try {
result.state = await readFile(join(gsdDir, 'STATE.md'), 'utf-8');
} catch {
result.state = null;
}
// PROJECT.md — project description
try {
result.project = await readFile(join(gsdDir, 'PROJECT.md'), 'utf-8');
} catch {
result.project = null;
}
// REQUIREMENTS.md — requirement contract
try {
result.requirements = await readFile(join(gsdDir, 'REQUIREMENTS.md'), 'utf-8');
} catch {
result.requirements = null;
}
// List milestones with basic metadata
const milestonesDir = join(gsdDir, 'milestones');
try {
const entries = await readdir(milestonesDir, { withFileTypes: true });
const milestones: Array<{ id: string; hasRoadmap: boolean; hasSummary: boolean }> = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const mDir = join(milestonesDir, entry.name);
const hasRoadmap = await fileExists(join(mDir, `${entry.name}-ROADMAP.md`));
const hasSummary = await fileExists(join(mDir, `${entry.name}-SUMMARY.md`));
milestones.push({ id: entry.name, hasRoadmap, hasSummary });
}
result.milestones = milestones;
} catch {
result.milestones = [];
}
return result;
}
async function fileExists(path: string): Promise<boolean> {
try {
await stat(path);
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// MCP Server type — minimal interface for the dynamically-imported McpServer
// ---------------------------------------------------------------------------
interface McpServerInstance {
tool(name: string, description: string, params: Record<string, unknown>, handler: (args: Record<string, unknown>) => Promise<unknown>): unknown;
connect(transport: unknown): Promise<void>;
close(): Promise<void>;
}
// ---------------------------------------------------------------------------
// createMcpServer
// ---------------------------------------------------------------------------
/**
* Create and configure an MCP server with 6 GSD orchestration tools.
*
* Returns the McpServer instance call `connect(transport)` to start serving.
* Uses dynamic imports for the MCP SDK to avoid TS subpath resolution issues.
*/
export async function createMcpServer(sessionManager: SessionManager): Promise<{
server: McpServerInstance;
}> {
// Dynamic import — same workaround as src/mcp-server.ts
const mcpMod = await import(`${MCP_PKG}/server/mcp.js`);
const McpServer = mcpMod.McpServer;
const server: McpServerInstance = new McpServer(
{ name: SERVER_NAME, version: SERVER_VERSION },
{ capabilities: { tools: {} } },
);
// -----------------------------------------------------------------------
// gsd_execute — start a new GSD auto-mode session
// -----------------------------------------------------------------------
server.tool(
'gsd_execute',
'Start a GSD auto-mode session for a project directory. Returns a sessionId for tracking.',
{
projectDir: z.string().describe('Absolute path to the project directory'),
command: z.string().optional().describe('Command to send (default: "/gsd auto")'),
model: z.string().optional().describe('Model ID override'),
bare: z.boolean().optional().describe('Run in bare mode (skip user config)'),
},
async (args: Record<string, unknown>) => {
const { projectDir, command, model, bare } = args as {
projectDir: string; command?: string; model?: string; bare?: boolean;
};
try {
const sessionId = await sessionManager.startSession(projectDir, { command, model, bare });
return jsonContent({ sessionId, status: 'started' });
} catch (err) {
return errorContent(err instanceof Error ? err.message : String(err));
}
},
);
// -----------------------------------------------------------------------
// gsd_status — poll session status
// -----------------------------------------------------------------------
server.tool(
'gsd_status',
'Get the current status of a GSD session including progress, recent events, and pending blockers.',
{
sessionId: z.string().describe('Session ID returned from gsd_execute'),
},
async (args: Record<string, unknown>) => {
const { sessionId } = args as { sessionId: string };
try {
const session = sessionManager.getSession(sessionId);
if (!session) return errorContent(`Session not found: ${sessionId}`);
const durationMs = Date.now() - session.startTime;
const toolCallCount = session.events.filter(
(e) => (e as Record<string, unknown>).type === 'tool_use' ||
(e as Record<string, unknown>).type === 'tool_execution_start'
).length;
return jsonContent({
status: session.status,
progress: {
eventCount: session.events.length,
toolCalls: toolCallCount,
},
recentEvents: session.events.slice(-10),
pendingBlocker: session.pendingBlocker
? {
id: session.pendingBlocker.id,
method: session.pendingBlocker.method,
message: session.pendingBlocker.message,
}
: null,
cost: session.cost,
durationMs,
});
} catch (err) {
return errorContent(err instanceof Error ? err.message : String(err));
}
},
);
// -----------------------------------------------------------------------
// gsd_result — get accumulated session result
// -----------------------------------------------------------------------
server.tool(
'gsd_result',
'Get the result of a GSD session. Returns partial results if the session is still running.',
{
sessionId: z.string().describe('Session ID returned from gsd_execute'),
},
async (args: Record<string, unknown>) => {
const { sessionId } = args as { sessionId: string };
try {
const result = sessionManager.getResult(sessionId);
return jsonContent(result);
} catch (err) {
return errorContent(err instanceof Error ? err.message : String(err));
}
},
);
// -----------------------------------------------------------------------
// gsd_cancel — cancel a running session
// -----------------------------------------------------------------------
server.tool(
'gsd_cancel',
'Cancel a running GSD session. Aborts the current operation and stops the process.',
{
sessionId: z.string().describe('Session ID returned from gsd_execute'),
},
async (args: Record<string, unknown>) => {
const { sessionId } = args as { sessionId: string };
try {
await sessionManager.cancelSession(sessionId);
return jsonContent({ cancelled: true });
} catch (err) {
return errorContent(err instanceof Error ? err.message : String(err));
}
},
);
// -----------------------------------------------------------------------
// gsd_query — read project state from filesystem (no session needed)
// -----------------------------------------------------------------------
server.tool(
'gsd_query',
'Query GSD project state from the filesystem. Returns STATE.md, PROJECT.md, requirements, and milestone listing. Does not require an active session.',
{
projectDir: z.string().describe('Absolute path to the project directory'),
query: z.string().describe('What to query (e.g. "status", "milestones", "requirements")'),
},
async (args: Record<string, unknown>) => {
const { projectDir, query } = args as { projectDir: string; query: string };
try {
const state = await readProjectState(projectDir, query);
return jsonContent(state);
} catch (err) {
return errorContent(err instanceof Error ? err.message : String(err));
}
},
);
// -----------------------------------------------------------------------
// gsd_resolve_blocker — resolve a pending blocker
// -----------------------------------------------------------------------
server.tool(
'gsd_resolve_blocker',
'Resolve a pending blocker in a GSD session by sending a response to the UI request.',
{
sessionId: z.string().describe('Session ID returned from gsd_execute'),
response: z.string().describe('Response to send for the pending blocker'),
},
async (args: Record<string, unknown>) => {
const { sessionId, response } = args as { sessionId: string; response: string };
try {
await sessionManager.resolveBlocker(sessionId, response);
return jsonContent({ resolved: true });
} catch (err) {
return errorContent(err instanceof Error ? err.message : String(err));
}
},
);
return { server };
}

View file

@ -0,0 +1,328 @@
/**
* SessionManager manages RpcClient lifecycle for background GSD execution.
*
* One active session per projectDir. Tracks events in a ring buffer,
* detects blockers, tracks terminal state, and accumulates cost using
* the cumulative-max pattern (K004).
*/
import { execSync } from 'node:child_process';
import { resolve } from 'node:path';
import { RpcClient } from '@gsd/rpc-client';
import type { SdkAgentEvent, RpcInitResult, RpcCostUpdateEvent, RpcExtensionUIRequest } from '@gsd/rpc-client';
import type {
ManagedSession,
ExecuteOptions,
PendingBlocker,
CostAccumulator,
SessionStatus,
} from './types.js';
import { MAX_EVENTS, INIT_TIMEOUT_MS } from './types.js';
// ---------------------------------------------------------------------------
// Inlined detection logic (from headless-events.ts — no internal package imports)
// ---------------------------------------------------------------------------
const FIRE_AND_FORGET_METHODS = new Set([
'notify', 'setStatus', 'setWidget', 'setTitle', 'set_editor_text',
]);
const TERMINAL_PREFIXES = ['auto-mode stopped', 'step-mode stopped'];
function isTerminalNotification(event: Record<string, unknown>): boolean {
if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false;
const message = String(event.message ?? '').toLowerCase();
return TERMINAL_PREFIXES.some((prefix) => message.startsWith(prefix));
}
function isBlockedNotification(event: Record<string, unknown>): boolean {
if (event.type !== 'extension_ui_request' || event.method !== 'notify') return false;
const message = String(event.message ?? '').toLowerCase();
return message.includes('blocked:');
}
function isBlockingUIRequest(event: Record<string, unknown>): boolean {
if (event.type !== 'extension_ui_request') return false;
const method = String(event.method ?? '');
return !FIRE_AND_FORGET_METHODS.has(method);
}
// ---------------------------------------------------------------------------
// SessionManager
// ---------------------------------------------------------------------------
export class SessionManager {
/** Sessions keyed by projectDir for duplicate-start prevention */
private sessions = new Map<string, ManagedSession>();
/**
* Start a new GSD auto-mode session for the given project directory.
*
* Rejects if a session already exists for this projectDir.
* Creates an RpcClient, starts the process, performs the v2 init handshake,
* wires event tracking, and sends '/gsd auto' to begin execution.
*/
async startSession(projectDir: string, options: ExecuteOptions = {}): Promise<string> {
if (!projectDir || projectDir.trim() === '') {
throw new Error('projectDir is required and cannot be empty');
}
const resolvedDir = resolve(projectDir);
const existing = this.sessions.get(resolvedDir);
if (existing) {
throw new Error(
`Session already active for ${resolvedDir} (sessionId: ${existing.sessionId}, status: ${existing.status})`
);
}
const cliPath = options.cliPath ?? SessionManager.resolveCLIPath();
const args: string[] = ['--mode', 'rpc'];
if (options.model) args.push('--model', options.model);
if (options.bare) args.push('--bare');
const client = new RpcClient({
cliPath,
cwd: resolvedDir,
args,
});
// Build the session shell before async operations so we can track state
const session: ManagedSession = {
sessionId: '', // filled after init
projectDir: resolvedDir,
status: 'starting',
client,
events: [],
pendingBlocker: null,
cost: { totalCost: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } },
startTime: Date.now(),
};
// Insert into map early (keyed by dir) so concurrent starts are rejected
this.sessions.set(resolvedDir, session);
try {
// Start the process with timeout
await Promise.race([
client.start(),
timeout(INIT_TIMEOUT_MS, `RpcClient.start() timed out after ${INIT_TIMEOUT_MS}ms`),
]);
// Perform v2 init handshake
const initResult: RpcInitResult = await Promise.race([
client.init(),
timeout(INIT_TIMEOUT_MS, `RpcClient.init() timed out after ${INIT_TIMEOUT_MS}ms`),
]) as RpcInitResult;
session.sessionId = initResult.sessionId;
session.status = 'running';
// Wire event tracking
session.unsubscribe = client.onEvent((event: SdkAgentEvent) => {
this.handleEvent(session, event);
});
// Kick off auto-mode
const command = options.command ?? '/gsd auto';
await client.prompt(command);
return session.sessionId;
} catch (err) {
session.status = 'error';
session.error = err instanceof Error ? err.message : String(err);
// Attempt cleanup
try { await client.stop(); } catch { /* swallow cleanup errors */ }
// Keep session in map so callers can inspect the error
throw new Error(`Failed to start session for ${resolvedDir}: ${session.error}`);
}
}
/**
* Look up a session by sessionId.
* Linear scan is fine we expect <10 concurrent sessions.
*/
getSession(sessionId: string): ManagedSession | undefined {
for (const session of this.sessions.values()) {
if (session.sessionId === sessionId) return session;
}
return undefined;
}
/**
* Look up a session by project directory (direct map lookup).
*/
getSessionByDir(projectDir: string): ManagedSession | undefined {
return this.sessions.get(resolve(projectDir));
}
/**
* Resolve a pending blocker by sending a UI response.
*/
async resolveBlocker(sessionId: string, response: string): Promise<void> {
const session = this.getSession(sessionId);
if (!session) throw new Error(`Session not found: ${sessionId}`);
if (!session.pendingBlocker) throw new Error(`No pending blocker for session ${sessionId}`);
const blocker = session.pendingBlocker;
session.client.sendUIResponse(blocker.id, { value: response });
session.pendingBlocker = null;
if (session.status === 'blocked') {
session.status = 'running';
}
}
/**
* Cancel a running session abort current operation then stop the process.
*/
async cancelSession(sessionId: string): Promise<void> {
const session = this.getSession(sessionId);
if (!session) throw new Error(`Session not found: ${sessionId}`);
try {
await session.client.abort();
} catch { /* may already be stopped */ }
try {
await session.client.stop();
} catch { /* swallow */ }
session.status = 'cancelled';
session.unsubscribe?.();
}
/**
* Build a HeadlessJsonResult-shaped object from accumulated session state.
*/
getResult(sessionId: string): Record<string, unknown> {
const session = this.getSession(sessionId);
if (!session) throw new Error(`Session not found: ${sessionId}`);
const durationMs = Date.now() - session.startTime;
return {
sessionId: session.sessionId,
projectDir: session.projectDir,
status: session.status,
durationMs,
cost: session.cost,
recentEvents: session.events.slice(-10),
pendingBlocker: session.pendingBlocker
? { id: session.pendingBlocker.id, method: session.pendingBlocker.method, message: session.pendingBlocker.message }
: null,
error: session.error ?? null,
};
}
/**
* Stop all active sessions and clean up resources.
*/
async cleanup(): Promise<void> {
const stopPromises: Promise<void>[] = [];
for (const session of this.sessions.values()) {
session.unsubscribe?.();
if (session.status === 'running' || session.status === 'starting' || session.status === 'blocked') {
stopPromises.push(
session.client.stop().catch(() => { /* swallow */ })
);
session.status = 'cancelled';
}
}
await Promise.allSettled(stopPromises);
}
/**
* Resolve the GSD CLI path.
*
* 1. GSD_CLI_PATH env var (highest priority)
* 2. `which gsd` resolve to the actual dist/cli.js
*/
static resolveCLIPath(): string {
// Check env var first
const envPath = process.env['GSD_CLI_PATH'];
if (envPath) return resolve(envPath);
// Fallback: locate `gsd` via which
try {
const gsdBin = execSync('which gsd', { encoding: 'utf-8' }).trim();
if (gsdBin) {
// gsd bin is typically a symlink to dist/loader.js — return the resolved path
return resolve(gsdBin);
}
} catch {
// which failed
}
throw new Error(
'Cannot find GSD CLI. Set GSD_CLI_PATH environment variable or ensure `gsd` is in PATH.'
);
}
// ---------------------------------------------------------------------------
// Private: Event Handling
// ---------------------------------------------------------------------------
private handleEvent(session: ManagedSession, event: SdkAgentEvent): void {
// Ring buffer: push and trim
session.events.push(event);
if (session.events.length > MAX_EVENTS) {
session.events.splice(0, session.events.length - MAX_EVENTS);
}
// Cost tracking (K004 — cumulative-max)
if (event.type === 'cost_update') {
const costEvent = event as unknown as RpcCostUpdateEvent;
session.cost.totalCost = Math.max(session.cost.totalCost, costEvent.cumulativeCost ?? 0);
if (costEvent.tokens) {
session.cost.tokens.input = Math.max(session.cost.tokens.input, costEvent.tokens.input ?? 0);
session.cost.tokens.output = Math.max(session.cost.tokens.output, costEvent.tokens.output ?? 0);
session.cost.tokens.cacheRead = Math.max(session.cost.tokens.cacheRead, costEvent.tokens.cacheRead ?? 0);
session.cost.tokens.cacheWrite = Math.max(session.cost.tokens.cacheWrite, costEvent.tokens.cacheWrite ?? 0);
}
}
// Terminal detection — auto-mode/step-mode stopped
if (isTerminalNotification(event as Record<string, unknown>)) {
// Check if it's a blocked stop (not truly terminal — it's a blocker)
if (isBlockedNotification(event as Record<string, unknown>)) {
session.status = 'blocked';
session.pendingBlocker = extractBlocker(event);
} else {
session.status = 'completed';
session.unsubscribe?.();
}
return;
}
// Blocker detection — non-fire-and-forget extension_ui_request
if (isBlockingUIRequest(event as Record<string, unknown>)) {
session.status = 'blocked';
session.pendingBlocker = extractBlocker(event);
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function timeout(ms: number, message: string): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error(message)), ms);
});
}
function extractBlocker(event: SdkAgentEvent): PendingBlocker {
const uiEvent = event as unknown as RpcExtensionUIRequest;
return {
id: String(uiEvent.id ?? ''),
method: String(uiEvent.method ?? ''),
message: String((uiEvent as Record<string, unknown>).title ?? (uiEvent as Record<string, unknown>).message ?? ''),
event: uiEvent,
};
}

View file

@ -0,0 +1,107 @@
/**
* MCP Server types session lifecycle and orchestration.
*/
import type { RpcClient, SdkAgentEvent, RpcCostUpdateEvent, RpcExtensionUIRequest } from '@gsd/rpc-client';
// ---------------------------------------------------------------------------
// Session Status
// ---------------------------------------------------------------------------
export type SessionStatus = 'starting' | 'running' | 'blocked' | 'completed' | 'error' | 'cancelled';
// ---------------------------------------------------------------------------
// Managed Session
// ---------------------------------------------------------------------------
export interface ManagedSession {
/** Unique session ID returned from RpcClient.init() */
sessionId: string;
/** Absolute path to the project directory */
projectDir: string;
/** Current lifecycle status */
status: SessionStatus;
/** The RpcClient instance managing the agent process */
client: RpcClient;
/** Ring buffer of recent events (capped at MAX_EVENTS) */
events: SdkAgentEvent[];
/** Pending blocker requiring user response, if any */
pendingBlocker: PendingBlocker | null;
/** Cumulative cost tracking (max pattern per K004) */
cost: CostAccumulator;
/** Session start timestamp */
startTime: number;
/** Error message if status is 'error' */
error?: string;
/** Cleanup function to unsubscribe from events */
unsubscribe?: () => void;
}
// ---------------------------------------------------------------------------
// Pending Blocker
// ---------------------------------------------------------------------------
export interface PendingBlocker {
/** The extension_ui_request id */
id: string;
/** The request method (e.g. 'select', 'confirm', 'input') */
method: string;
/** Human-readable message or title */
message: string;
/** Full event payload for inspection */
event: RpcExtensionUIRequest;
}
// ---------------------------------------------------------------------------
// Cost Accumulator (K004 — cumulative-max)
// ---------------------------------------------------------------------------
export interface CostAccumulator {
totalCost: number;
tokens: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
};
}
// ---------------------------------------------------------------------------
// Execute Options
// ---------------------------------------------------------------------------
export interface ExecuteOptions {
/** Command to send after '/gsd auto' (default: none) */
command?: string;
/** Model ID override */
model?: string;
/** Run in bare mode (skip user config) */
bare?: boolean;
/** Path to CLI binary (overrides GSD_CLI_PATH and which resolution) */
cliPath?: string;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Maximum number of events kept in the ring buffer */
export const MAX_EVENTS = 50;
/** Timeout for RpcClient initialization (ms) */
export const INIT_TIMEOUT_MS = 30_000;

View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "Node16",
"lib": ["ES2024"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"inlineSources": true,
"inlineSourceMap": false,
"moduleResolution": "Node16",
"resolveJsonModule": true,
"allowImportingTsExtensions": false,
"types": ["node"],
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"]
}

View file

@ -0,0 +1,20 @@
{
"name": "@gsd/rpc-client",
"version": "2.51.0",
"description": "Standalone RPC client SDK for GSD — zero internal dependencies",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"engines": {
"node": ">=22.0.0"
}
}