@shaykec/bridge 0.4.17 → 0.4.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaykec/bridge",
3
- "version": "0.4.17",
3
+ "version": "0.4.19",
4
4
  "type": "module",
5
5
  "description": "Communication hub — HTTP + WebSocket + SSE server with template engine",
6
6
  "main": "src/server.js",
@@ -20,15 +20,23 @@ import {
20
20
  MSG_CHAT_STATUS,
21
21
  } from '@shaykec/shared';
22
22
 
23
+ import { randomUUID } from 'crypto';
23
24
  import { execSync } from 'child_process';
24
25
  import { createRequire } from 'module';
26
+ import { dirname, join } from 'path';
27
+ import { fileURLToPath } from 'url';
28
+
29
+ // Resolve the package tree root (parent of node_modules/@shaykec/bridge)
30
+ const __filename = fileURLToPath(import.meta.url);
31
+ const BRIDGE_ROOT = join(dirname(__filename), '..');
32
+ // Walk up: bridge/ -> @shaykec/ -> node_modules/ -> prefix/
33
+ const NPM_PREFIX = join(BRIDGE_ROOT, '..', '..', '..');
25
34
 
26
35
  // Lazy-load the Agent SDK — auto-installs on first use if missing
27
36
  let _sdk = null;
28
37
  let _sdkLoadError = null;
29
38
 
30
39
  function tryRequireSDK() {
31
- // Use createRequire to avoid Node's ESM import cache (which caches failures)
32
40
  const require = createRequire(import.meta.url);
33
41
  return require('@anthropic-ai/claude-agent-sdk');
34
42
  }
@@ -40,13 +48,13 @@ async function getSDK() {
40
48
  _sdk = tryRequireSDK();
41
49
  return _sdk;
42
50
  } catch {
43
- // SDK not installed — try to install it automatically
51
+ // SDK not installed — install it into the same node_modules tree
44
52
  console.log('[chat] Agent SDK not found, installing @anthropic-ai/claude-agent-sdk...');
45
53
  try {
46
- execSync('npm install @anthropic-ai/claude-agent-sdk --registry https://registry.npmjs.org/ --no-save', {
47
- stdio: 'inherit',
48
- timeout: 60000,
49
- });
54
+ execSync(
55
+ `npm install @anthropic-ai/claude-agent-sdk --registry https://registry.npmjs.org/ --prefix "${NPM_PREFIX}" --no-save`,
56
+ { stdio: 'inherit', timeout: 60000 }
57
+ );
50
58
  _sdk = tryRequireSDK();
51
59
  console.log('[chat] Agent SDK installed successfully.');
52
60
  return _sdk;
@@ -78,9 +86,18 @@ async function getSDK() {
78
86
  * Manages multiple Claude Code SDK sessions.
79
87
  */
80
88
  export class ClaudeSessionManager {
81
- constructor() {
89
+ /**
90
+ * @param {object} [sdkOverride] - Injected SDK for testing
91
+ */
92
+ constructor(sdkOverride) {
82
93
  /** @type {Map<string, SessionEntry>} */
83
94
  this._sessions = new Map();
95
+ this._sdkOverride = sdkOverride || null;
96
+ }
97
+
98
+ /** @private */
99
+ async _getSDK() {
100
+ return this._sdkOverride || getSDK();
84
101
  }
85
102
 
86
103
  /**
@@ -89,7 +106,7 @@ export class ClaudeSessionManager {
89
106
  */
90
107
  async isAvailable() {
91
108
  try {
92
- await getSDK();
109
+ await this._getSDK();
93
110
  return true;
94
111
  } catch {
95
112
  return false;
@@ -134,21 +151,24 @@ export class ClaudeSessionManager {
134
151
 
135
152
  /**
136
153
  * Create a new chat session.
154
+ *
155
+ * The SDK's sessionId isn't available until after the first message
156
+ * exchange, so we generate our own UUID to return immediately and
157
+ * capture the real SDK sessionId lazily during sendMessage().
158
+ *
137
159
  * @param {ChatSessionOptions} options
138
- * @returns {Promise<string>} sessionId
160
+ * @returns {Promise<string>} sessionId (our UUID handle)
139
161
  */
140
162
  async createSession(options = {}) {
141
- const sdk = await getSDK();
163
+ const sdk = await this._getSDK();
142
164
  const onMessage = options.onMessage;
143
165
 
144
166
  const sessionOpts = {
167
+ model: options.model || 'claude-sonnet-4-6',
145
168
  allowedTools: [
146
169
  'Bash(*)', 'Read', 'Write', 'Edit', 'Glob', 'Grep',
147
170
  ],
148
171
  permissionMode: 'bypassPermissions',
149
- allowDangerouslySkipPermissions: true,
150
- settingSources: ['user', 'project'],
151
- includePartialMessages: true,
152
172
  };
153
173
 
154
174
  if (options.cwd) sessionOpts.cwd = options.cwd;
@@ -159,12 +179,15 @@ export class ClaudeSessionManager {
159
179
 
160
180
  try {
161
181
  const session = sdk.unstable_v2_createSession(sessionOpts);
162
- const sessionId = session.sessionId;
182
+ // session.sessionId throws until after first send()+stream()
183
+ // so we use our own UUID as the map key
184
+ const sessionId = randomUUID();
163
185
 
164
186
  this._sessions.set(sessionId, {
165
187
  session,
166
188
  streaming: false,
167
189
  onMessage,
190
+ sdkSessionId: null, // captured after first message
168
191
  });
169
192
 
170
193
  this._emit(sessionId, MSG_CHAT_STATUS, { status: 'started', sessionId });
@@ -186,7 +209,7 @@ export class ClaudeSessionManager {
186
209
  * @returns {Promise<string>} sessionId
187
210
  */
188
211
  async resumeSession(sessionId, options = {}) {
189
- const sdk = await getSDK();
212
+ const sdk = await this._getSDK();
190
213
 
191
214
  // Close existing entry for this ID if present
192
215
  await this.closeSession(sessionId);
@@ -194,13 +217,11 @@ export class ClaudeSessionManager {
194
217
  const onMessage = options.onMessage;
195
218
 
196
219
  const sessionOpts = {
220
+ model: options.model || 'claude-sonnet-4-6',
197
221
  allowedTools: [
198
222
  'Bash(*)', 'Read', 'Write', 'Edit', 'Glob', 'Grep',
199
223
  ],
200
224
  permissionMode: 'bypassPermissions',
201
- allowDangerouslySkipPermissions: true,
202
- settingSources: ['user', 'project'],
203
- includePartialMessages: true,
204
225
  };
205
226
 
206
227
  if (options.cwd) sessionOpts.cwd = options.cwd;
@@ -210,12 +231,14 @@ export class ClaudeSessionManager {
210
231
  }
211
232
 
212
233
  try {
234
+ // For resumed sessions, sessionId is available immediately
213
235
  const session = sdk.unstable_v2_resumeSession(sessionId, sessionOpts);
214
236
 
215
237
  this._sessions.set(sessionId, {
216
238
  session,
217
239
  streaming: false,
218
240
  onMessage,
241
+ sdkSessionId: sessionId,
219
242
  });
220
243
 
221
244
  this._emit(sessionId, MSG_CHAT_STATUS, { status: 'resumed', sessionId });
@@ -255,6 +278,13 @@ export class ClaudeSessionManager {
255
278
  let currentText = '';
256
279
 
257
280
  for await (const msg of entry.session.stream()) {
281
+ // Capture the real SDK sessionId once it becomes available
282
+ if (!entry.sdkSessionId) {
283
+ try {
284
+ entry.sdkSessionId = entry.session.sessionId;
285
+ } catch { /* not ready yet */ }
286
+ }
287
+
258
288
  if (msg.type === 'assistant') {
259
289
  const textBlocks = (msg.message?.content || []).filter(b => b.type === 'text');
260
290
  for (const block of textBlocks) {
@@ -298,6 +328,16 @@ export class ClaudeSessionManager {
298
328
  }
299
329
  }
300
330
 
331
+ /**
332
+ * Get the real SDK session ID for a session (available after first message).
333
+ * @param {string} sessionId - Our UUID handle
334
+ * @returns {string|null}
335
+ */
336
+ getSdkSessionId(sessionId) {
337
+ const entry = this._sessions.get(sessionId);
338
+ return entry?.sdkSessionId || null;
339
+ }
340
+
301
341
  /**
302
342
  * Stop the current generation for a specific session.
303
343
  * @param {string} sessionId
@@ -320,7 +360,7 @@ export class ClaudeSessionManager {
320
360
  */
321
361
  async listSessions(dir) {
322
362
  try {
323
- const sdk = await getSDK();
363
+ const sdk = await this._getSDK();
324
364
  const sessions = await sdk.listSessions({ dir, limit: 20 });
325
365
  return sessions.map(s => ({
326
366
  sessionId: s.sessionId,
@@ -1,32 +1,10 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { ClaudeSessionManager } from './claude-session.js';
3
3
 
4
- // Mock the SDK
5
- vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
6
- unstable_v2_createSession: vi.fn(),
7
- unstable_v2_resumeSession: vi.fn(),
8
- listSessions: vi.fn(),
9
- }));
10
-
11
- import {
12
- unstable_v2_createSession,
13
- unstable_v2_resumeSession,
14
- listSessions,
15
- } from '@anthropic-ai/claude-agent-sdk';
16
-
17
4
  describe('ClaudeSessionManager', () => {
18
5
  let manager;
19
6
  let emittedMessages;
20
-
21
- beforeEach(() => {
22
- vi.clearAllMocks();
23
- manager = new ClaudeSessionManager();
24
- emittedMessages = [];
25
- });
26
-
27
- const mockOnMessage = (envelope) => {
28
- emittedMessages.push(envelope);
29
- };
7
+ let mockSDK;
30
8
 
31
9
  function makeMockSession(id) {
32
10
  return {
@@ -37,23 +15,40 @@ describe('ClaudeSessionManager', () => {
37
15
  };
38
16
  }
39
17
 
18
+ beforeEach(() => {
19
+ vi.clearAllMocks();
20
+ emittedMessages = [];
21
+ mockSDK = {
22
+ unstable_v2_createSession: vi.fn(),
23
+ unstable_v2_resumeSession: vi.fn(),
24
+ listSessions: vi.fn(),
25
+ };
26
+ manager = new ClaudeSessionManager(mockSDK);
27
+ });
28
+
29
+ const mockOnMessage = (envelope) => {
30
+ emittedMessages.push(envelope);
31
+ };
32
+
40
33
  describe('createSession', () => {
41
34
  it('creates a session and emits started status', async () => {
42
- const mockSession = makeMockSession('test-session-123');
43
- unstable_v2_createSession.mockReturnValue(mockSession);
35
+ const mockSession = makeMockSession('sdk-id-1');
36
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
44
37
 
45
38
  const sessionId = await manager.createSession({ onMessage: mockOnMessage });
46
39
 
47
- expect(sessionId).toBe('test-session-123');
48
- expect(manager.isActive('test-session-123')).toBe(true);
40
+ // sessionId is a UUID we generate, not the SDK's
41
+ expect(sessionId).toMatch(/^[0-9a-f-]{36}$/);
42
+ expect(manager.isActive(sessionId)).toBe(true);
49
43
  expect(emittedMessages).toHaveLength(1);
50
44
  expect(emittedMessages[0].type).toBe('chat:status');
51
45
  expect(emittedMessages[0].payload.status).toBe('started');
46
+ expect(emittedMessages[0].payload.sessionId).toBe(sessionId);
52
47
  });
53
48
 
54
- it('passes cwd and pluginDir to the SDK', async () => {
49
+ it('passes model, cwd and pluginDir to the SDK', async () => {
55
50
  const mockSession = makeMockSession('s1');
56
- unstable_v2_createSession.mockReturnValue(mockSession);
51
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
57
52
 
58
53
  await manager.createSession({
59
54
  cwd: '/test/dir',
@@ -61,13 +56,14 @@ describe('ClaudeSessionManager', () => {
61
56
  onMessage: mockOnMessage,
62
57
  });
63
58
 
64
- const opts = unstable_v2_createSession.mock.calls[0][0];
59
+ const opts = mockSDK.unstable_v2_createSession.mock.calls[0][0];
60
+ expect(opts.model).toBe('claude-sonnet-4-6');
65
61
  expect(opts.cwd).toBe('/test/dir');
66
62
  expect(opts.plugins).toEqual([{ type: 'local', path: '/test/plugin' }]);
67
63
  });
68
64
 
69
65
  it('emits error status on SDK failure', async () => {
70
- unstable_v2_createSession.mockImplementation(() => {
66
+ mockSDK.unstable_v2_createSession.mockImplementation(() => {
71
67
  throw new Error('SDK init failed');
72
68
  });
73
69
 
@@ -83,12 +79,12 @@ describe('ClaudeSessionManager', () => {
83
79
  describe('resumeSession', () => {
84
80
  it('resumes a session by ID', async () => {
85
81
  const mockSession = makeMockSession('existing-session');
86
- unstable_v2_resumeSession.mockReturnValue(mockSession);
82
+ mockSDK.unstable_v2_resumeSession.mockReturnValue(mockSession);
87
83
 
88
84
  const sessionId = await manager.resumeSession('existing-session', { onMessage: mockOnMessage });
89
85
 
90
86
  expect(sessionId).toBe('existing-session');
91
- expect(unstable_v2_resumeSession).toHaveBeenCalledWith('existing-session', expect.any(Object));
87
+ expect(mockSDK.unstable_v2_resumeSession).toHaveBeenCalledWith('existing-session', expect.any(Object));
92
88
  expect(emittedMessages[0].payload.status).toBe('resumed');
93
89
  });
94
90
  });
@@ -106,7 +102,7 @@ describe('ClaudeSessionManager', () => {
106
102
 
107
103
  const mockSession = makeMockSession('stream-test');
108
104
  mockSession.stream.mockReturnValue(fakeStream());
109
- unstable_v2_createSession.mockReturnValue(mockSession);
105
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
110
106
  const sessionId = await manager.createSession({ onMessage: mockOnMessage });
111
107
 
112
108
  emittedMessages = [];
@@ -121,6 +117,25 @@ describe('ClaudeSessionManager', () => {
121
117
  expect(types).toContain('chat:assistant');
122
118
  });
123
119
 
120
+ it('captures SDK sessionId during streaming', async () => {
121
+ async function* fakeStream() {
122
+ yield { type: 'system' };
123
+ yield { type: 'assistant', message: { content: [{ type: 'text', text: 'Hi' }] } };
124
+ yield { type: 'result' };
125
+ }
126
+
127
+ const mockSession = makeMockSession('real-sdk-id');
128
+ mockSession.stream.mockReturnValue(fakeStream());
129
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
130
+ const sessionId = await manager.createSession({ onMessage: mockOnMessage });
131
+
132
+ expect(manager.getSdkSessionId(sessionId)).toBeNull();
133
+
134
+ await manager.sendMessage(sessionId, 'test');
135
+
136
+ expect(manager.getSdkSessionId(sessionId)).toBe('real-sdk-id');
137
+ });
138
+
124
139
  it('emits tool-use and tool-result events', async () => {
125
140
  async function* fakeStream() {
126
141
  yield { type: 'tool_use', name: 'Bash', id: 'tool-1', input: { command: 'ls' } };
@@ -130,7 +145,7 @@ describe('ClaudeSessionManager', () => {
130
145
 
131
146
  const mockSession = makeMockSession('tool-test');
132
147
  mockSession.stream.mockReturnValue(fakeStream());
133
- unstable_v2_createSession.mockReturnValue(mockSession);
148
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
134
149
  const sessionId = await manager.createSession({ onMessage: mockOnMessage });
135
150
 
136
151
  emittedMessages = [];
@@ -148,9 +163,9 @@ describe('ClaudeSessionManager', () => {
148
163
 
149
164
  describe('multiple sessions', () => {
150
165
  it('supports multiple concurrent sessions', async () => {
151
- const session1 = makeMockSession('session-1');
152
- const session2 = makeMockSession('session-2');
153
- unstable_v2_createSession
166
+ const session1 = makeMockSession('sdk-1');
167
+ const session2 = makeMockSession('sdk-2');
168
+ mockSDK.unstable_v2_createSession
154
169
  .mockReturnValueOnce(session1)
155
170
  .mockReturnValueOnce(session2);
156
171
 
@@ -160,37 +175,36 @@ describe('ClaudeSessionManager', () => {
160
175
  const id1 = await manager.createSession({ onMessage: (e) => messages1.push(e) });
161
176
  const id2 = await manager.createSession({ onMessage: (e) => messages2.push(e) });
162
177
 
163
- expect(id1).toBe('session-1');
164
- expect(id2).toBe('session-2');
165
- expect(manager.isActive('session-1')).toBe(true);
166
- expect(manager.isActive('session-2')).toBe(true);
178
+ expect(id1).not.toBe(id2);
179
+ expect(manager.isActive(id1)).toBe(true);
180
+ expect(manager.isActive(id2)).toBe(true);
167
181
  expect(manager.getActiveSessionIds()).toHaveLength(2);
168
182
 
169
183
  // Status messages go to the right callback
170
- expect(messages1[0].payload.sessionId).toBe('session-1');
171
- expect(messages2[0].payload.sessionId).toBe('session-2');
184
+ expect(messages1[0].payload.sessionId).toBe(id1);
185
+ expect(messages2[0].payload.sessionId).toBe(id2);
172
186
  });
173
187
 
174
188
  it('closing one session does not affect others', async () => {
175
- const session1 = makeMockSession('session-1');
176
- const session2 = makeMockSession('session-2');
177
- unstable_v2_createSession
189
+ const session1 = makeMockSession('sdk-1');
190
+ const session2 = makeMockSession('sdk-2');
191
+ mockSDK.unstable_v2_createSession
178
192
  .mockReturnValueOnce(session1)
179
193
  .mockReturnValueOnce(session2);
180
194
 
181
- await manager.createSession({ onMessage: mockOnMessage });
182
- await manager.createSession({ onMessage: mockOnMessage });
195
+ const id1 = await manager.createSession({ onMessage: mockOnMessage });
196
+ const id2 = await manager.createSession({ onMessage: mockOnMessage });
183
197
 
184
- await manager.closeSession('session-1');
198
+ await manager.closeSession(id1);
185
199
 
186
- expect(manager.isActive('session-1')).toBe(false);
187
- expect(manager.isActive('session-2')).toBe(true);
200
+ expect(manager.isActive(id1)).toBe(false);
201
+ expect(manager.isActive(id2)).toBe(true);
188
202
  });
189
203
 
190
204
  it('closeAll closes all sessions', async () => {
191
- const session1 = makeMockSession('session-1');
192
- const session2 = makeMockSession('session-2');
193
- unstable_v2_createSession
205
+ const session1 = makeMockSession('sdk-1');
206
+ const session2 = makeMockSession('sdk-2');
207
+ mockSDK.unstable_v2_createSession
194
208
  .mockReturnValueOnce(session1)
195
209
  .mockReturnValueOnce(session2);
196
210
 
@@ -208,7 +222,7 @@ describe('ClaudeSessionManager', () => {
208
222
  describe('stop', () => {
209
223
  it('is a no-op when not streaming', async () => {
210
224
  const mockSession = makeMockSession('s1');
211
- unstable_v2_createSession.mockReturnValue(mockSession);
225
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
212
226
  const sessionId = await manager.createSession({ onMessage: mockOnMessage });
213
227
 
214
228
  emittedMessages = [];
@@ -222,7 +236,7 @@ describe('ClaudeSessionManager', () => {
222
236
 
223
237
  describe('listSessions', () => {
224
238
  it('returns mapped session list', async () => {
225
- listSessions.mockResolvedValue([
239
+ mockSDK.listSessions.mockResolvedValue([
226
240
  { sessionId: 's1', summary: 'Test', lastModified: 12345 },
227
241
  { sessionId: 's2', summary: 'Other', lastModified: 67890 },
228
242
  ]);
@@ -233,7 +247,7 @@ describe('ClaudeSessionManager', () => {
233
247
  });
234
248
 
235
249
  it('returns empty array on error', async () => {
236
- listSessions.mockRejectedValue(new Error('fail'));
250
+ mockSDK.listSessions.mockRejectedValue(new Error('fail'));
237
251
  const sessions = await manager.listSessions('/test');
238
252
  expect(sessions).toEqual([]);
239
253
  });
@@ -242,7 +256,7 @@ describe('ClaudeSessionManager', () => {
242
256
  describe('closeSession', () => {
243
257
  it('closes active session and removes from map', async () => {
244
258
  const mockSession = makeMockSession('s1');
245
- unstable_v2_createSession.mockReturnValue(mockSession);
259
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
246
260
  const sessionId = await manager.createSession({ onMessage: mockOnMessage });
247
261
 
248
262
  expect(manager.isActive(sessionId)).toBe(true);
@@ -252,7 +266,7 @@ describe('ClaudeSessionManager', () => {
252
266
  });
253
267
 
254
268
  describe('isAvailable', () => {
255
- it('returns true when SDK is loadable', async () => {
269
+ it('returns true when SDK is available', async () => {
256
270
  const available = await manager.isAvailable();
257
271
  expect(available).toBe(true);
258
272
  });
@@ -268,7 +282,7 @@ describe('ClaudeSessionManager', () => {
268
282
 
269
283
  const mockSession = makeMockSession('busy');
270
284
  mockSession.stream.mockReturnValue(slowStream());
271
- unstable_v2_createSession.mockReturnValue(mockSession);
285
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
272
286
  const sessionId = await manager.createSession({ onMessage: mockOnMessage });
273
287
 
274
288
  // Start streaming (don't await — it won't finish)
@@ -290,7 +304,7 @@ describe('ClaudeSessionManager', () => {
290
304
 
291
305
  const mockSession = makeMockSession('fail-stream');
292
306
  mockSession.stream.mockReturnValue(failStream());
293
- unstable_v2_createSession.mockReturnValue(mockSession);
307
+ mockSDK.unstable_v2_createSession.mockReturnValue(mockSession);
294
308
  const sessionId = await manager.createSession({ onMessage: mockOnMessage });
295
309
 
296
310
  emittedMessages = [];
@@ -787,10 +787,15 @@ describe('Bridge E2E: Chat message routing', () => {
787
787
  expect(data.ok).toBe(true);
788
788
  });
789
789
 
790
- it('POST /api/chat/start fails without SDK but returns structured error', async () => {
790
+ it('POST /api/chat/start returns 200 with sessionId (SDK available) or 500 with error', async () => {
791
791
  const { status, data } = await postJson('/api/chat/start', { clientId: 'test-client-123' });
792
- expect(status).toBe(500);
793
- expect(data.error).toBeDefined();
792
+ expect([200, 500]).toContain(status);
793
+ if (status === 200) {
794
+ expect(data.sessionId).toBeDefined();
795
+ expect(data.ok).toBe(true);
796
+ } else {
797
+ expect(data.error).toBeDefined();
798
+ }
794
799
  });
795
800
 
796
801
  it('POST /api/chat/message requires sessionId', async () => {