happy-mcp-server 0.1.0 → 0.1.1

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.
@@ -12,12 +12,12 @@ export function readCredentials(path) {
12
12
  const raw = readFileSync(path, 'utf-8');
13
13
  const parsed = JSON.parse(raw);
14
14
  if (!parsed.token || !parsed.secret) {
15
- logger.warn('Credentials file missing token or secret');
15
+ logger.debug('Credentials file missing token or secret');
16
16
  return null;
17
17
  }
18
18
  const secret = decodeBase64(parsed.secret);
19
19
  if (secret.length !== 32) {
20
- logger.warn('Credentials secret is not 32 bytes');
20
+ logger.debug('Credentials secret is not 32 bytes');
21
21
  return null;
22
22
  }
23
23
  const contentKeyPair = deriveContentKeyPair(secret);
@@ -27,7 +27,7 @@ export function readCredentials(path) {
27
27
  if (err.code === 'ENOENT') {
28
28
  return null; // File doesn't exist, first run
29
29
  }
30
- logger.warn('Failed to read credentials:', err.message);
30
+ logger.debug('Failed to read credentials:', err.message);
31
31
  return null;
32
32
  }
33
33
  }
package/dist/index.js CHANGED
@@ -27,9 +27,14 @@ function sessionMatchesFilters(metadata, config) {
27
27
  async function initializeAuthenticatedState(config, credentials) {
28
28
  const sessionManager = new SessionManager(config.sessionCacheTtl);
29
29
  const api = new ApiClient(credentials, config);
30
- // Fetch initial sessions
31
- try {
32
- const rawSessions = await api.listActiveSessions();
30
+ // Fetch initial data in parallel
31
+ const [sessionsResult, machinesResult] = await Promise.allSettled([
32
+ api.listActiveSessions(),
33
+ api.listMachines(),
34
+ ]);
35
+ // Process sessions
36
+ if (sessionsResult.status === 'fulfilled') {
37
+ const rawSessions = sessionsResult.value;
33
38
  logger.info(`Fetched ${rawSessions.length} active sessions`);
34
39
  for (const raw of rawSessions) {
35
40
  try {
@@ -47,17 +52,17 @@ async function initializeAuthenticatedState(config, credentials) {
47
52
  sessionManager.loadSession(raw.id, encryption, metadata, raw.metadataVersion, agentState, raw.agentStateVersion, raw.active, raw.createdAt, raw.updatedAt);
48
53
  }
49
54
  catch (err) {
50
- logger.warn(`Failed to load session ${raw.id}:`, err.message);
55
+ logger.debug(`Failed to load session ${raw.id}:`, err.message);
51
56
  }
52
57
  }
53
58
  logger.info(`Loaded ${sessionManager.getAll().length} sessions into cache`);
54
59
  }
55
- catch (err) {
56
- logger.error('Failed to fetch initial sessions:', err.message);
60
+ else {
61
+ logger.debug('Failed to fetch initial sessions:', sessionsResult.reason?.message ?? sessionsResult.reason);
57
62
  }
58
- // Fetch machines
59
- try {
60
- const rawMachines = await api.listMachines();
63
+ // Process machines
64
+ if (machinesResult.status === 'fulfilled') {
65
+ const rawMachines = machinesResult.value;
61
66
  logger.info(`Fetched ${rawMachines.length} machines`);
62
67
  for (const raw of rawMachines) {
63
68
  try {
@@ -65,32 +70,29 @@ async function initializeAuthenticatedState(config, credentials) {
65
70
  const metadata = raw.metadata
66
71
  ? decryptFromBase64(encryption, raw.metadata)
67
72
  : null;
73
+ const daemonState = raw.daemonState
74
+ ? decryptFromBase64(encryption, raw.daemonState)
75
+ : null;
68
76
  sessionManager.loadMachine({
69
77
  machineId: raw.id, metadata,
70
78
  metadataVersion: raw.metadataVersion,
71
- daemonState: null,
79
+ daemonState,
72
80
  daemonStateVersion: raw.daemonStateVersion ?? 0,
73
81
  encryption, active: raw.active, activeAt: raw.activeAt,
74
82
  });
75
83
  }
76
84
  catch (err) {
77
- logger.warn(`Failed to load machine ${raw.id}:`, err.message);
85
+ logger.debug(`Failed to load machine ${raw.id}:`, err.message);
78
86
  }
79
87
  }
80
88
  logger.info(`Loaded ${sessionManager.getAllMachines().length} machines into cache`);
81
89
  }
82
- catch (err) {
83
- logger.error('Failed to fetch machines:', err.message);
90
+ else {
91
+ logger.debug('Failed to fetch machines:', machinesResult.reason?.message ?? machinesResult.reason);
84
92
  }
85
- // Connect relay
93
+ // Connect relay (non-blocking, fire-and-forget)
86
94
  const relay = new RelayClient(credentials, sessionManager, config);
87
- try {
88
- await relay.connect();
89
- logger.info('Relay connected');
90
- }
91
- catch (err) {
92
- logger.error('Failed to connect relay:', err.message);
93
- }
95
+ relay.connect();
94
96
  // Reconnection catch-up
95
97
  relay.on('connected', async () => {
96
98
  try {
@@ -110,17 +112,39 @@ async function initializeAuthenticatedState(config, credentials) {
110
112
  sessionManager.loadSession(raw.id, encryption, metadata, raw.metadataVersion, agentState, raw.agentStateVersion, raw.active, raw.createdAt, raw.updatedAt);
111
113
  }
112
114
  catch (err) {
113
- logger.warn(`Catch-up: failed to load session ${raw.id}:`, err.message);
115
+ logger.debug(`Catch-up: failed to load session ${raw.id}:`, err.message);
114
116
  }
115
117
  }
116
118
  }
117
119
  catch (err) {
118
- logger.warn('Reconnect catch-up failed:', err.message);
120
+ logger.debug('Reconnect catch-up failed:', err.message);
119
121
  }
120
122
  });
121
123
  relay.on('disconnected', () => {
122
124
  relay.updateToken(credentials.token);
123
125
  });
126
+ // Handle new session events
127
+ relay.on('new_session', (body) => {
128
+ try {
129
+ const sessionBody = body;
130
+ const encryption = resolveSessionEncryptionCached(sessionBody.dataEncryptionKey, credentials);
131
+ const metadata = sessionBody.metadata
132
+ ? decryptFromBase64(encryption, sessionBody.metadata)
133
+ : null;
134
+ const agentState = sessionBody.agentState
135
+ ? decryptFromBase64(encryption, sessionBody.agentState)
136
+ : null;
137
+ if (!sessionMatchesFilters(metadata, config)) {
138
+ logger.debug(`Skipping new session ${sessionBody.id} -- doesn't match filters`);
139
+ return;
140
+ }
141
+ sessionManager.loadSession(sessionBody.id, encryption, metadata, sessionBody.metadataVersion, agentState, sessionBody.agentStateVersion, sessionBody.active, sessionBody.createdAt, sessionBody.updatedAt);
142
+ logger.info(`New session ${sessionBody.id} added to cache`);
143
+ }
144
+ catch (err) {
145
+ logger.debug('Failed to handle new_session event:', err.message);
146
+ }
147
+ });
124
148
  return { api, relay, sessionManager };
125
149
  }
126
150
  // ---------------------------------------------------------------------------
@@ -194,7 +218,7 @@ async function runServer() {
194
218
  validateFilePermissions(config.credentialsPath);
195
219
  }
196
220
  catch (err) {
197
- logger.error('Credential file has unsafe permissions:', err.message);
221
+ logger.debug('Credential file has unsafe permissions:', err.message);
198
222
  return false;
199
223
  }
200
224
  authState.activatingPromise = (async () => {
@@ -208,7 +232,7 @@ async function runServer() {
208
232
  return true;
209
233
  }
210
234
  catch (err) {
211
- logger.error('Lazy auth failed:', err.message);
235
+ logger.debug('Lazy auth failed:', err.message);
212
236
  return false;
213
237
  }
214
238
  finally {
@@ -219,29 +243,27 @@ async function runServer() {
219
243
  }
220
244
  const { server, activate } = createUnauthenticatedServer(config, tryActivate);
221
245
  activateFn = activate;
222
- // Startup optimization: check for existing credentials
246
+ // Connect stdio transport FIRST (Issue 1: prevent blocking)
247
+ const transport = new StdioServerTransport();
248
+ await server.connect(transport);
249
+ logger.info('MCP server stdio transport connected');
250
+ // Then initialize auth state via tryActivate() only (consolidate startup path)
223
251
  const initialCreds = readCredentials(config.credentialsPath);
224
252
  if (initialCreds) {
225
253
  try {
226
254
  validateFilePermissions(config.credentialsPath);
227
- logger.info('Credentials found at startup');
228
- const state = await initializeAuthenticatedState(config, initialCreds);
229
- authState.relay = state.relay;
230
- authState.sessionManager = state.sessionManager;
231
- activate(state.api, state.relay, state.sessionManager);
232
- authState.authenticated = true;
255
+ logger.info('Credentials found at startup, initializing...');
256
+ await tryActivate();
233
257
  }
234
258
  catch (err) {
235
- logger.error('Failed to initialize with existing credentials:', err.message);
259
+ logger.debug('Failed to initialize with existing credentials:', err.message);
236
260
  }
237
261
  }
238
262
  else {
239
- logger.warn('No credentials found. Tools will attempt auth on each call.');
240
- logger.warn('Run `happy-mcp auth` to authenticate.');
263
+ logger.debug('No credentials found. Tools will attempt auth on each call.');
264
+ logger.debug('Run `happy-mcp auth` to authenticate.');
241
265
  }
242
- const transport = new StdioServerTransport();
243
- await server.connect(transport);
244
- logger.info(`MCP server started on stdio (${authState.authenticated ? 'authenticated' : 'unauthenticated'} mode)`);
266
+ logger.info(`MCP server started (${authState.authenticated ? 'authenticated' : 'unauthenticated'} mode)`);
245
267
  const shutdown = async () => {
246
268
  logger.info('Shutting down...');
247
269
  authState.relay?.disconnect();
@@ -2,21 +2,24 @@ import { EventEmitter } from 'events';
2
2
  import type { Credentials } from '../auth/crypto.js';
3
3
  import type { SessionManager } from '../session/manager.js';
4
4
  import type { Config } from '../config.js';
5
+ export type RelayState = 'disconnected' | 'connecting' | 'connected';
5
6
  export declare class RelayClient extends EventEmitter {
6
7
  private socket;
7
8
  private credentials;
8
9
  private sessionManager;
9
10
  private config;
10
- private _connected;
11
+ private _state;
12
+ private connectErrorCount;
11
13
  constructor(credentials: Credentials, sessionManager: SessionManager, config: Config);
14
+ get state(): RelayState;
12
15
  get connected(): boolean;
13
- connect(): Promise<void>;
14
- private waitForConnect;
16
+ connect(): void;
15
17
  private handleUpdate;
16
18
  private handleNewMessage;
17
19
  private handleUpdateSession;
18
20
  private handleUpdateMachine;
19
21
  private handleDeleteSession;
22
+ private handleEphemeral;
20
23
  /**
21
24
  * Encrypted RPC call to a session.
22
25
  * Event: 'rpc-call' (NOT 'rpc-request' -- asymmetric names)
@@ -8,17 +8,25 @@ export class RelayClient extends EventEmitter {
8
8
  credentials;
9
9
  sessionManager;
10
10
  config;
11
- _connected = false;
11
+ _state = 'disconnected';
12
+ connectErrorCount = 0;
12
13
  constructor(credentials, sessionManager, config) {
13
14
  super();
14
15
  this.credentials = credentials;
15
16
  this.sessionManager = sessionManager;
16
17
  this.config = config;
17
18
  }
19
+ get state() {
20
+ return this._state;
21
+ }
18
22
  get connected() {
19
- return this._connected;
23
+ return this._state === 'connected';
20
24
  }
21
- async connect() {
25
+ connect() {
26
+ // Idempotent guard
27
+ if (this.socket)
28
+ return;
29
+ this._state = 'connecting';
22
30
  this.socket = io(this.config.serverUrl, {
23
31
  path: '/v1/updates',
24
32
  transports: ['websocket'],
@@ -33,46 +41,49 @@ export class RelayClient extends EventEmitter {
33
41
  autoConnect: false,
34
42
  });
35
43
  this.socket.on('connect', () => {
36
- this._connected = true;
44
+ this._state = 'connected';
45
+ this.connectErrorCount = 0;
37
46
  logger.info('Relay connected');
38
47
  this.emit('connected');
39
48
  });
40
49
  this.socket.on('disconnect', (reason) => {
41
- this._connected = false;
42
- logger.warn('Relay disconnected:', reason);
50
+ const wasConnected = this._state === 'connected';
51
+ this._state = 'connecting'; // socket.io auto-reconnects
52
+ if (wasConnected) {
53
+ logger.debug('Relay disconnected:', reason);
54
+ }
55
+ else {
56
+ logger.debug('Relay disconnected during connection attempt:', reason);
57
+ }
43
58
  // Session cache is PRESERVED -- do NOT clear sessionManager
44
59
  this.emit('disconnected', reason);
45
60
  });
46
61
  this.socket.on('connect_error', (err) => {
47
- logger.error('Relay connection error:', err.message);
62
+ this.connectErrorCount++;
63
+ if (this.connectErrorCount === 1) {
64
+ logger.debug('Relay connection error:', err.message);
65
+ }
66
+ else {
67
+ logger.debug('Relay connection error (attempt', this.connectErrorCount, '):', err.message);
68
+ }
48
69
  });
49
70
  this.socket.on('update', (data) => {
50
71
  try {
51
72
  this.handleUpdate(data);
52
73
  }
53
74
  catch (err) {
54
- logger.error('Error handling update:', err.message);
75
+ logger.debug('Error handling update:', err.message);
55
76
  }
56
77
  });
57
- this.socket.connect();
58
- await this.waitForConnect(10_000);
59
- }
60
- async waitForConnect(timeoutMs) {
61
- if (this._connected)
62
- return;
63
- return new Promise((resolve, reject) => {
64
- const timeout = setTimeout(() => {
65
- reject(new RelayError(`Relay connection timed out after ${timeoutMs}ms`));
66
- }, timeoutMs);
67
- this.socket.once('connect', () => {
68
- clearTimeout(timeout);
69
- resolve();
70
- });
71
- this.socket.once('connect_error', (err) => {
72
- clearTimeout(timeout);
73
- reject(new RelayError(`Relay connection failed: ${err.message}`));
74
- });
78
+ this.socket.on('ephemeral', (data) => {
79
+ try {
80
+ this.handleEphemeral(data);
81
+ }
82
+ catch (err) {
83
+ logger.debug('Error handling ephemeral:', err.message);
84
+ }
75
85
  });
86
+ this.socket.connect();
76
87
  }
77
88
  handleUpdate(data) {
78
89
  const container = data;
@@ -111,7 +122,7 @@ export class RelayClient extends EventEmitter {
111
122
  return;
112
123
  const decrypted = decrypt(session.encryption.key, session.encryption.variant, decodeBase64(message.content.c));
113
124
  if (decrypted === null) {
114
- logger.warn(`Failed to decrypt message ${message.id} for session ${sessionId}`);
125
+ logger.debug(`Failed to decrypt message ${message.id} for session ${sessionId}`);
115
126
  return;
116
127
  }
117
128
  this.sessionManager.applyMessage(sessionId, message.id ?? '', message.seq ?? 0, decrypted, message.createdAt ?? Date.now());
@@ -168,12 +179,33 @@ export class RelayClient extends EventEmitter {
168
179
  this.emit('machine_update', machineId);
169
180
  }
170
181
  handleDeleteSession(body) {
171
- const sessionId = body.id;
182
+ const sessionId = body.sid;
172
183
  if (sessionId) {
173
184
  this.sessionManager.remove(sessionId);
174
185
  this.emit('session_deleted', sessionId);
175
186
  }
176
187
  }
188
+ handleEphemeral(data) {
189
+ const event = data;
190
+ if (!event?.type)
191
+ return;
192
+ switch (event.type) {
193
+ case 'machine-activity':
194
+ if (event.id && event.active !== undefined) {
195
+ const machine = this.sessionManager.getMachine(event.id);
196
+ if (machine) {
197
+ machine.active = event.active;
198
+ if (event.activeAt !== undefined) {
199
+ machine.activeAt = event.activeAt;
200
+ }
201
+ this.emit('machine_update', event.id);
202
+ }
203
+ }
204
+ break;
205
+ default:
206
+ logger.debug('Unhandled ephemeral type:', event.type);
207
+ }
208
+ }
177
209
  /**
178
210
  * Encrypted RPC call to a session.
179
211
  * Event: 'rpc-call' (NOT 'rpc-request' -- asymmetric names)
@@ -181,7 +213,7 @@ export class RelayClient extends EventEmitter {
181
213
  * Response is encrypted with session key.
182
214
  */
183
215
  async sessionRpc(sessionId, method, params) {
184
- if (!this._connected || !this.socket) {
216
+ if (!this.connected || !this.socket) {
185
217
  throw new RelayError('Relay not connected');
186
218
  }
187
219
  const session = this.sessionManager.get(sessionId);
@@ -206,7 +238,7 @@ export class RelayClient extends EventEmitter {
206
238
  * Used for start_session (spawn-happy-session).
207
239
  */
208
240
  async machineRpc(machineId, method, params) {
209
- if (!this._connected || !this.socket) {
241
+ if (!this.connected || !this.socket) {
210
242
  throw new RelayError('Relay not connected');
211
243
  }
212
244
  const machine = this.sessionManager.getMachine(machineId);
@@ -237,6 +269,7 @@ export class RelayClient extends EventEmitter {
237
269
  this.socket.disconnect();
238
270
  this.socket = null;
239
271
  }
240
- this._connected = false;
272
+ this._state = 'disconnected';
273
+ this.connectErrorCount = 0;
241
274
  }
242
275
  }
@@ -91,6 +91,14 @@ export interface RawMachine {
91
91
  createdAt: number;
92
92
  updatedAt: number;
93
93
  }
94
+ export interface MachineMetadata {
95
+ host: string;
96
+ platform: string;
97
+ happyCliVersion: string;
98
+ homeDir: string;
99
+ happyHomeDir: string;
100
+ happyLibDir: string;
101
+ }
94
102
  export type SessionStatus = 'active' | 'idle' | 'waiting_permission';
95
103
  export interface PermissionRequest {
96
104
  requestId: string;
@@ -10,7 +10,13 @@ export function registerAnswerQuestion(server, api, relay, sessionManager) {
10
10
  try {
11
11
  // Check relay is connected (REQUIRED for answer_question)
12
12
  if (!relay.connected) {
13
- return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'RelayDisconnected', message: 'Relay must be connected to answer questions' }) }] };
13
+ const msg = relay.state === 'connecting'
14
+ ? 'Relay is still connecting. Please try again in a few seconds.'
15
+ : 'Relay must be connected to answer questions.';
16
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({
17
+ error: relay.state === 'connecting' ? 'RelayConnecting' : 'RelayDisconnected',
18
+ message: msg,
19
+ }) }] };
14
20
  }
15
21
  const session = sessionManager.get(sessionId);
16
22
  if (!session) {
@@ -12,7 +12,13 @@ export function registerApprovePermission(server, relay, sessionManager) {
12
12
  }, async ({ sessionId, requestId, mode, allowTools, decision }) => {
13
13
  try {
14
14
  if (!relay.connected) {
15
- return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'RelayDisconnected', message: 'Relay is not connected' }) }] };
15
+ const msg = relay.state === 'connecting'
16
+ ? 'Relay is still connecting. Please try again in a few seconds.'
17
+ : 'Relay is disconnected.';
18
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({
19
+ error: relay.state === 'connecting' ? 'RelayConnecting' : 'RelayDisconnected',
20
+ message: msg,
21
+ }) }] };
16
22
  }
17
23
  const session = sessionManager.get(sessionId);
18
24
  if (!session) {
@@ -9,7 +9,13 @@ export function registerDenyPermission(server, relay, sessionManager) {
9
9
  }, async ({ sessionId, requestId, reason, decision }) => {
10
10
  try {
11
11
  if (!relay.connected) {
12
- return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'RelayDisconnected', message: 'Relay is not connected' }) }] };
12
+ const msg = relay.state === 'connecting'
13
+ ? 'Relay is still connecting. Please try again in a few seconds.'
14
+ : 'Relay is disconnected.';
15
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({
16
+ error: relay.state === 'connecting' ? 'RelayConnecting' : 'RelayDisconnected',
17
+ message: msg,
18
+ }) }] };
13
19
  }
14
20
  const session = sessionManager.get(sessionId);
15
21
  if (!session) {
@@ -1,16 +1,16 @@
1
1
  export function registerListComputers(server, sessionManager, config) {
2
2
  return server.tool('list_computers', 'List available computers filtered by HAPPY_MCP_COMPUTERS. Shows hostname, online status, and active session count. Use before start_session to find available machines.', {}, async () => {
3
3
  try {
4
- const machines = sessionManager.getAllMachines()
5
- .filter(m => m.active)
4
+ const allMachines = sessionManager.getAllMachines();
5
+ const machines = allMachines
6
6
  .filter(m => {
7
7
  const meta = m.metadata;
8
- const hostname = (meta?.host ?? meta?.displayName ?? '').toLowerCase();
8
+ const hostname = (meta?.host ?? '').toLowerCase();
9
9
  return config.computers.some(c => c === '*' || c.toLowerCase() === hostname);
10
10
  });
11
11
  const result = machines.map(m => {
12
12
  const meta = m.metadata;
13
- const hostname = (meta?.host ?? meta?.displayName ?? 'unknown');
13
+ const hostname = meta?.host ?? 'unknown';
14
14
  // Count active sessions for this machine matching project paths
15
15
  const activeSessions = sessionManager.getAll().filter(s => {
16
16
  if (!s.active)
@@ -23,6 +23,7 @@ export function registerListComputers(server, sessionManager, config) {
23
23
  return {
24
24
  machineId: m.machineId,
25
25
  hostname,
26
+ online: m.active,
26
27
  activeAt: new Date(m.activeAt).toISOString(),
27
28
  activeSessions,
28
29
  };
@@ -10,7 +10,13 @@ export function registerStartSession(server, _api, relay, sessionManager) {
10
10
  }, async ({ computer, projectPath, initialMessage, permissionMode, agent }) => {
11
11
  try {
12
12
  if (!relay.connected) {
13
- return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'RelayDisconnected', message: 'Relay is not connected' }) }] };
13
+ const msg = relay.state === 'connecting'
14
+ ? 'Relay is still connecting. Please try again in a few seconds.'
15
+ : 'Relay is disconnected.';
16
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({
17
+ error: relay.state === 'connecting' ? 'RelayConnecting' : 'RelayDisconnected',
18
+ message: msg,
19
+ }) }] };
14
20
  }
15
21
  const machine = sessionManager.getMachine(computer);
16
22
  if (!machine) {
@@ -1,10 +1,11 @@
1
1
  import { z } from 'zod';
2
2
  const DEFAULT_TIMEOUT_SECONDS = 300; // 5 minutes
3
+ const MAX_WAIT_MS = 30_000; // 30s, safely under 60s MCP timeout
3
4
  export function registerWatchSession(server, relay, sessionManager) {
4
- return server.tool('watch_session', 'Watch one or more sessions until any becomes idle or has pending permissions. Returns final state of the triggering session. Use after send_message or approve_permission to wait for the agent to finish.', {
5
+ return server.tool('watch_session', 'Watch one or more sessions for state changes. Returns immediately if any session is idle or has pending permissions. Otherwise waits up to 30 seconds for a state change. If sessions are still active, returns current status -- call again to continue watching.', {
5
6
  sessionIds: z.array(z.string()).describe('One or more session IDs to watch'),
6
7
  timeoutSeconds: z.number().optional().default(DEFAULT_TIMEOUT_SECONDS).describe('Max wait time in seconds (default 300)'),
7
- }, async ({ sessionIds, timeoutSeconds }) => {
8
+ }, async ({ sessionIds, timeoutSeconds }, extra) => {
8
9
  try {
9
10
  // Validate all session IDs exist
10
11
  const missing = sessionIds.filter(id => !sessionManager.get(id));
@@ -12,7 +13,13 @@ export function registerWatchSession(server, relay, sessionManager) {
12
13
  return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'SessionNotFound', message: `Sessions not found: ${missing.join(', ')}` }) }] };
13
14
  }
14
15
  if (!relay.connected) {
15
- return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'RelayDisconnected', message: 'Relay is not connected' }) }] };
16
+ const msg = relay.state === 'connecting'
17
+ ? 'Relay is still connecting. Please try again in a few seconds.'
18
+ : 'Relay is disconnected.';
19
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({
20
+ error: relay.state === 'connecting' ? 'RelayConnecting' : 'RelayDisconnected',
21
+ message: msg,
22
+ }) }] };
16
23
  }
17
24
  const watchedSet = new Set(sessionIds);
18
25
  // Check if any session is already in a terminal state
@@ -38,12 +45,30 @@ export function registerWatchSession(server, relay, sessionManager) {
38
45
  }
39
46
  // Wait for state change on any watched session
40
47
  const startTime = Date.now();
41
- const timeoutMs = timeoutSeconds * 1000;
48
+ const effectiveTimeout = Math.min(timeoutSeconds * 1000, MAX_WAIT_MS);
42
49
  const result = await new Promise((resolve) => {
43
50
  const timeout = setTimeout(() => {
44
51
  cleanup();
45
- resolve({ triggeredBy: sessionIds[0], status: 'timeout', timedOut: true });
46
- }, timeoutMs);
52
+ resolve({ triggeredBy: sessionIds[0], status: 'active', timedOut: false });
53
+ }, effectiveTimeout);
54
+ const progressToken = extra._meta?.progressToken;
55
+ const heartbeat = setInterval(async () => {
56
+ if (progressToken) {
57
+ try {
58
+ await extra.sendNotification({
59
+ method: 'notifications/progress',
60
+ params: {
61
+ progressToken,
62
+ progress: Math.round((Date.now() - startTime) / 1000),
63
+ total: Math.round(effectiveTimeout / 1000),
64
+ },
65
+ });
66
+ }
67
+ catch {
68
+ // Client may not support progress
69
+ }
70
+ }
71
+ }, 10_000);
47
72
  const onSessionUpdate = (sid) => {
48
73
  if (!watchedSet.has(sid))
49
74
  return;
@@ -59,15 +84,44 @@ export function registerWatchSession(server, relay, sessionManager) {
59
84
  cleanup();
60
85
  resolve({ triggeredBy: sid, status: 'deleted', timedOut: false });
61
86
  };
87
+ const onAbort = () => {
88
+ cleanup();
89
+ resolve({ triggeredBy: sessionIds[0], status: 'cancelled', timedOut: false });
90
+ };
62
91
  const cleanup = () => {
63
92
  clearTimeout(timeout);
93
+ clearInterval(heartbeat);
64
94
  relay.removeListener('session_update', onSessionUpdate);
65
95
  relay.removeListener('session_deleted', onDeleted);
96
+ extra.signal?.removeEventListener('abort', onAbort);
66
97
  };
67
98
  relay.on('session_update', onSessionUpdate);
68
99
  relay.on('session_deleted', onDeleted);
100
+ extra.signal?.addEventListener('abort', onAbort);
69
101
  });
70
102
  const waitedSeconds = Math.round((Date.now() - startTime) / 1000);
103
+ // Handle "still active" case
104
+ if (result.status === 'active' || result.status === 'cancelled') {
105
+ return {
106
+ content: [{
107
+ type: 'text',
108
+ text: JSON.stringify({
109
+ triggeredBy: null,
110
+ status: result.status,
111
+ waitedSeconds,
112
+ timedOut: false,
113
+ message: result.status === 'cancelled'
114
+ ? 'Watch cancelled by client.'
115
+ : 'Sessions are still active. Call watch_session again to continue watching.',
116
+ sessions: sessionIds.map(sid => ({
117
+ sessionId: sid,
118
+ status: sessionManager.getSessionStatus(sid),
119
+ })),
120
+ }, null, 2),
121
+ }],
122
+ };
123
+ }
124
+ // Handle triggered state change (idle, waiting_permission, deleted)
71
125
  const permissions = sessionManager.getPendingPermissions(result.triggeredBy);
72
126
  const messages = sessionManager.getMessages(result.triggeredBy, 5);
73
127
  return {
@@ -36,11 +36,20 @@ export interface UpdateMachineBody {
36
36
  export interface NewSessionBody {
37
37
  t: 'new-session';
38
38
  id: string;
39
- [key: string]: unknown;
39
+ seq: number;
40
+ metadata: string;
41
+ metadataVersion: number;
42
+ agentState: string | null;
43
+ agentStateVersion: number;
44
+ dataEncryptionKey: string | null;
45
+ active: boolean;
46
+ activeAt: number;
47
+ createdAt: number;
48
+ updatedAt: number;
40
49
  }
41
50
  export interface DeleteSessionBody {
42
51
  t: 'delete-session';
43
- id: string;
52
+ sid: string;
44
53
  }
45
54
  export interface VersionedField {
46
55
  version: number;
@@ -70,6 +79,7 @@ export interface LegacyUserMessage {
70
79
  export interface LegacyMessageMeta {
71
80
  sentFrom: string;
72
81
  permissionMode?: PermissionMode;
82
+ displayText?: string;
73
83
  [key: string]: unknown;
74
84
  }
75
85
  export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happy-mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "MCP server for observing and controlling Happy Coder sessions",
5
5
  "author": {
6
6
  "name": "Jared Spencer",