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.
- package/dist/auth/credentials.js +3 -3
- package/dist/index.js +60 -38
- package/dist/relay/client.d.ts +6 -3
- package/dist/relay/client.js +64 -31
- package/dist/session/types.d.ts +8 -0
- package/dist/tools/answer_question.js +7 -1
- package/dist/tools/approve_permission.js +7 -1
- package/dist/tools/deny_permission.js +7 -1
- package/dist/tools/list_computers.js +5 -4
- package/dist/tools/start_session.js +7 -1
- package/dist/tools/watch_session.js +60 -6
- package/dist/types/wire.d.ts +12 -2
- package/package.json +1 -1
package/dist/auth/credentials.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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.
|
|
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
|
-
|
|
56
|
-
logger.
|
|
60
|
+
else {
|
|
61
|
+
logger.debug('Failed to fetch initial sessions:', sessionsResult.reason?.message ?? sessionsResult.reason);
|
|
57
62
|
}
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
const rawMachines =
|
|
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
|
|
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.
|
|
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
|
-
|
|
83
|
-
logger.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
259
|
+
logger.debug('Failed to initialize with existing credentials:', err.message);
|
|
236
260
|
}
|
|
237
261
|
}
|
|
238
262
|
else {
|
|
239
|
-
logger.
|
|
240
|
-
logger.
|
|
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
|
-
|
|
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();
|
package/dist/relay/client.d.ts
CHANGED
|
@@ -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
|
|
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():
|
|
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)
|
package/dist/relay/client.js
CHANGED
|
@@ -8,17 +8,25 @@ export class RelayClient extends EventEmitter {
|
|
|
8
8
|
credentials;
|
|
9
9
|
sessionManager;
|
|
10
10
|
config;
|
|
11
|
-
|
|
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.
|
|
23
|
+
return this._state === 'connected';
|
|
20
24
|
}
|
|
21
|
-
|
|
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.
|
|
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.
|
|
42
|
-
|
|
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
|
-
|
|
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.
|
|
75
|
+
logger.debug('Error handling update:', err.message);
|
|
55
76
|
}
|
|
56
77
|
});
|
|
57
|
-
this.socket.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
272
|
+
this._state = 'disconnected';
|
|
273
|
+
this.connectErrorCount = 0;
|
|
241
274
|
}
|
|
242
275
|
}
|
package/dist/session/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
|
|
4
|
+
const allMachines = sessionManager.getAllMachines();
|
|
5
|
+
const machines = allMachines
|
|
6
6
|
.filter(m => {
|
|
7
7
|
const meta = m.metadata;
|
|
8
|
-
const hostname = (meta?.host ??
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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: '
|
|
46
|
-
},
|
|
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 {
|
package/dist/types/wire.d.ts
CHANGED
|
@@ -36,11 +36,20 @@ export interface UpdateMachineBody {
|
|
|
36
36
|
export interface NewSessionBody {
|
|
37
37
|
t: 'new-session';
|
|
38
38
|
id: string;
|
|
39
|
-
|
|
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
|
-
|
|
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';
|