@kmmao/happy-agent 0.2.0

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/index.cjs ADDED
@@ -0,0 +1,1037 @@
1
+ 'use strict';
2
+
3
+ var commander = require('commander');
4
+ var node_os = require('node:os');
5
+ var node_path = require('node:path');
6
+ var node_fs = require('node:fs');
7
+ var node_crypto = require('node:crypto');
8
+ var tweetnacl = require('tweetnacl');
9
+ var axios = require('axios');
10
+ var qrcode = require('qrcode-terminal');
11
+ var node_events = require('node:events');
12
+ var socket_ioClient = require('socket.io-client');
13
+
14
+ var version = "0.2.0";
15
+
16
+ function loadConfig() {
17
+ const serverUrl = (process.env.HAPPY_SERVER_URL ?? "https://api.cluster-fluster.com").replace(/\/+$/, "");
18
+ const homeDir = process.env.HAPPY_HOME_DIR ?? node_path.join(node_os.homedir(), ".happy");
19
+ const credentialPath = node_path.join(homeDir, "agent.key");
20
+ return { serverUrl, homeDir, credentialPath };
21
+ }
22
+
23
+ function encodeBase64(buffer) {
24
+ return Buffer.from(buffer).toString("base64");
25
+ }
26
+ function decodeBase64(base64) {
27
+ return new Uint8Array(Buffer.from(base64, "base64"));
28
+ }
29
+ function encodeBase64Url(buffer) {
30
+ return Buffer.from(buffer).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
31
+ }
32
+ function getRandomBytes(size) {
33
+ return new Uint8Array(node_crypto.randomBytes(size));
34
+ }
35
+ function hmac_sha512(key, data) {
36
+ const hmac = node_crypto.createHmac("sha512", key);
37
+ hmac.update(data);
38
+ return new Uint8Array(hmac.digest());
39
+ }
40
+ function deriveSecretKeyTreeRoot(seed, usage) {
41
+ const I = hmac_sha512(new TextEncoder().encode(usage + " Master Seed"), seed);
42
+ return {
43
+ key: I.slice(0, 32),
44
+ chainCode: I.slice(32)
45
+ };
46
+ }
47
+ function deriveSecretKeyTreeChild(chainCode, index) {
48
+ const data = new Uint8Array([0, ...new TextEncoder().encode(index)]);
49
+ const I = hmac_sha512(chainCode, data);
50
+ return {
51
+ key: I.slice(0, 32),
52
+ chainCode: I.slice(32)
53
+ };
54
+ }
55
+ function deriveKey(master, usage, path) {
56
+ let state = deriveSecretKeyTreeRoot(master, usage);
57
+ for (const index of path) {
58
+ state = deriveSecretKeyTreeChild(state.chainCode, index);
59
+ }
60
+ return state.key;
61
+ }
62
+ function deriveContentKeyPair(secret) {
63
+ const seed = deriveKey(secret, "Happy EnCoder", ["content"]);
64
+ const hashedSeed = new Uint8Array(node_crypto.createHash("sha512").update(seed).digest());
65
+ const boxSecretKey = hashedSeed.slice(0, 32);
66
+ const keyPair = tweetnacl.box.keyPair.fromSecretKey(boxSecretKey);
67
+ return { publicKey: keyPair.publicKey, secretKey: keyPair.secretKey };
68
+ }
69
+ function encryptWithDataKey(data, dataKey) {
70
+ const nonce = getRandomBytes(12);
71
+ const cipher = node_crypto.createCipheriv("aes-256-gcm", dataKey, nonce);
72
+ const plaintext = new TextEncoder().encode(JSON.stringify(data));
73
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
74
+ const authTag = cipher.getAuthTag();
75
+ const bundle = new Uint8Array(1 + 12 + encrypted.length + 16);
76
+ bundle[0] = 0;
77
+ bundle.set(nonce, 1);
78
+ bundle.set(new Uint8Array(encrypted), 13);
79
+ bundle.set(new Uint8Array(authTag), 13 + encrypted.length);
80
+ return bundle;
81
+ }
82
+ function decryptWithDataKey(bundle, dataKey) {
83
+ if (bundle.length < 1 + 12 + 16) return null;
84
+ if (bundle[0] !== 0) return null;
85
+ const nonce = bundle.slice(1, 13);
86
+ const authTag = bundle.slice(bundle.length - 16);
87
+ const ciphertext = bundle.slice(13, bundle.length - 16);
88
+ try {
89
+ const decipher = node_crypto.createDecipheriv("aes-256-gcm", dataKey, nonce);
90
+ decipher.setAuthTag(authTag);
91
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
92
+ return JSON.parse(new TextDecoder().decode(decrypted));
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+ function encryptLegacy(data, secret) {
98
+ const nonce = getRandomBytes(tweetnacl.secretbox.nonceLength);
99
+ const plaintext = new TextEncoder().encode(JSON.stringify(data));
100
+ const encrypted = tweetnacl.secretbox(plaintext, nonce, secret);
101
+ const result = new Uint8Array(nonce.length + encrypted.length);
102
+ result.set(nonce);
103
+ result.set(encrypted, nonce.length);
104
+ return result;
105
+ }
106
+ function decryptLegacy(data, secret) {
107
+ try {
108
+ const nonce = data.slice(0, tweetnacl.secretbox.nonceLength);
109
+ const encrypted = data.slice(tweetnacl.secretbox.nonceLength);
110
+ const decrypted = tweetnacl.secretbox.open(encrypted, nonce, secret);
111
+ if (!decrypted) return null;
112
+ return JSON.parse(new TextDecoder().decode(decrypted));
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+ function encrypt(key, variant, data) {
118
+ if (variant === "legacy") {
119
+ return encryptLegacy(data, key);
120
+ } else {
121
+ return encryptWithDataKey(data, key);
122
+ }
123
+ }
124
+ function decrypt(key, variant, data) {
125
+ if (variant === "legacy") {
126
+ return decryptLegacy(data, key);
127
+ } else {
128
+ return decryptWithDataKey(data, key);
129
+ }
130
+ }
131
+ function libsodiumEncryptForPublicKey(data, recipientPublicKey) {
132
+ const ephemeralKeyPair = tweetnacl.box.keyPair();
133
+ const nonce = getRandomBytes(tweetnacl.box.nonceLength);
134
+ const encrypted = tweetnacl.box(data, nonce, recipientPublicKey, ephemeralKeyPair.secretKey);
135
+ const result = new Uint8Array(32 + 24 + encrypted.length);
136
+ result.set(ephemeralKeyPair.publicKey, 0);
137
+ result.set(nonce, 32);
138
+ result.set(encrypted, 56);
139
+ return result;
140
+ }
141
+ function decryptBoxBundle(bundle, recipientSecretKey) {
142
+ if (bundle.length < 32 + 24) return null;
143
+ const ephemeralPublicKey = bundle.slice(0, 32);
144
+ const nonce = bundle.slice(32, 56);
145
+ const ciphertext = bundle.slice(56);
146
+ const decrypted = tweetnacl.box.open(ciphertext, nonce, ephemeralPublicKey, recipientSecretKey);
147
+ return decrypted ? new Uint8Array(decrypted) : null;
148
+ }
149
+
150
+ function readCredentials(config) {
151
+ try {
152
+ const raw = node_fs.readFileSync(config.credentialPath, "utf-8");
153
+ const parsed = JSON.parse(raw);
154
+ if (!parsed.token || !parsed.secret) return null;
155
+ const secret = decodeBase64(parsed.secret);
156
+ const contentKeyPair = deriveContentKeyPair(secret);
157
+ return {
158
+ token: parsed.token,
159
+ secret,
160
+ contentKeyPair
161
+ };
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+ function writeCredentials(config, token, secret) {
167
+ node_fs.mkdirSync(node_path.dirname(config.credentialPath), { recursive: true, mode: 448 });
168
+ const data = JSON.stringify({ token, secret: encodeBase64(secret) });
169
+ node_fs.writeFileSync(config.credentialPath, data, { mode: 384 });
170
+ }
171
+ function clearCredentials(config) {
172
+ try {
173
+ node_fs.unlinkSync(config.credentialPath);
174
+ } catch {
175
+ }
176
+ }
177
+ function requireCredentials(config) {
178
+ const creds = readCredentials(config);
179
+ if (!creds) {
180
+ throw new Error("Not authenticated. Run `happy-agent auth login` first.");
181
+ }
182
+ return creds;
183
+ }
184
+
185
+ const POLL_INTERVAL_MS = 1e3;
186
+ const AUTH_TIMEOUT_MS = 12e4;
187
+ async function authLogin(config) {
188
+ const seed = getRandomBytes(32);
189
+ const keypair = tweetnacl.box.keyPair.fromSecretKey(seed);
190
+ const publicKeyBase64 = encodeBase64(keypair.publicKey);
191
+ try {
192
+ await axios.post(`${config.serverUrl}/v1/auth/account/request`, {
193
+ publicKey: publicKeyBase64
194
+ });
195
+ } catch (err) {
196
+ if (err instanceof axios.AxiosError) {
197
+ throw new Error(`Failed to initiate auth: ${err.message}`);
198
+ }
199
+ throw err;
200
+ }
201
+ const qrData = `happy:///account?${encodeBase64Url(keypair.publicKey)}`;
202
+ console.log("");
203
+ qrcode.generate(qrData, { small: true }, (code) => {
204
+ console.log(code);
205
+ });
206
+ console.log("## Authentication");
207
+ console.log("- Action: Scan this QR code with the Happy app");
208
+ console.log("- Path: Settings -> Account -> Link New Device");
209
+ console.log("");
210
+ const startTime = Date.now();
211
+ while (Date.now() - startTime < AUTH_TIMEOUT_MS) {
212
+ await sleep(POLL_INTERVAL_MS);
213
+ let result;
214
+ try {
215
+ const resp = await axios.post(`${config.serverUrl}/v1/auth/account/request`, {
216
+ publicKey: publicKeyBase64
217
+ });
218
+ result = resp.data;
219
+ } catch (err) {
220
+ if (err instanceof axios.AxiosError) {
221
+ throw new Error(`Auth polling failed: ${err.message}`);
222
+ }
223
+ throw err;
224
+ }
225
+ if (result.state === "authorized" && result.token && result.response) {
226
+ const encryptedResponse = decodeBase64(result.response);
227
+ const secret = decryptBoxBundle(encryptedResponse, keypair.secretKey);
228
+ if (!secret) {
229
+ throw new Error("Failed to decrypt auth response");
230
+ }
231
+ writeCredentials(config, result.token, secret);
232
+ console.log("## Authentication");
233
+ console.log("- Status: Authenticated");
234
+ return;
235
+ }
236
+ }
237
+ throw new Error("Authentication timed out. Please try again.");
238
+ }
239
+ async function authLogout(config) {
240
+ clearCredentials(config);
241
+ console.log("## Authentication");
242
+ console.log("- Status: Logged out");
243
+ console.log("- Credentials: Cleared");
244
+ }
245
+ async function authStatus(config) {
246
+ const creds = readCredentials(config);
247
+ console.log("## Authentication");
248
+ if (creds) {
249
+ console.log("- Status: Authenticated");
250
+ console.log(`- Public Key: \`${encodeBase64(creds.contentKeyPair.publicKey)}\``);
251
+ } else {
252
+ console.log("- Status: Not authenticated");
253
+ console.log("- Action: Run `happy-agent auth login` to authenticate.");
254
+ }
255
+ }
256
+ function sleep(ms) {
257
+ return new Promise((resolve) => setTimeout(resolve, ms));
258
+ }
259
+
260
+ function resolveSessionEncryption(session, creds) {
261
+ if (session.dataEncryptionKey) {
262
+ const encrypted = decodeBase64(session.dataEncryptionKey);
263
+ const bundle = encrypted.slice(1);
264
+ const sessionKey = decryptBoxBundle(bundle, creds.contentKeyPair.secretKey);
265
+ if (!sessionKey) {
266
+ throw new Error(
267
+ `Failed to decrypt session key for session ${session.id}`
268
+ );
269
+ }
270
+ return { key: sessionKey, variant: "dataKey" };
271
+ }
272
+ return { key: creds.secret, variant: "legacy" };
273
+ }
274
+ function decryptField(encrypted, encryption) {
275
+ if (!encrypted) return null;
276
+ const data = decodeBase64(encrypted);
277
+ if (encryption.variant === "dataKey") {
278
+ return decryptWithDataKey(data, encryption.key);
279
+ }
280
+ return decryptLegacy(data, encryption.key);
281
+ }
282
+ function decryptSession(raw, creds) {
283
+ const encryption = resolveSessionEncryption(raw, creds);
284
+ return {
285
+ id: raw.id,
286
+ seq: raw.seq,
287
+ createdAt: raw.createdAt,
288
+ updatedAt: raw.updatedAt,
289
+ active: raw.active,
290
+ activeAt: raw.activeAt,
291
+ metadata: decryptField(raw.metadata, encryption),
292
+ agentState: decryptField(raw.agentState, encryption),
293
+ dataEncryptionKey: raw.dataEncryptionKey,
294
+ encryption
295
+ };
296
+ }
297
+ function handleApiError(err, context) {
298
+ if (err instanceof axios.AxiosError) {
299
+ const status = err.response?.status;
300
+ if (status === 401) {
301
+ throw new Error(
302
+ "Authentication expired. Run `happy-agent auth login` to re-authenticate."
303
+ );
304
+ }
305
+ if (status === 403) {
306
+ throw new Error(`Forbidden: ${context}. Check your account permissions.`);
307
+ }
308
+ if (status === 404) {
309
+ throw new Error(`Not found: ${context}`);
310
+ }
311
+ if (status && status >= 400 && status < 500) {
312
+ const detail = err.response?.data ? `: ${JSON.stringify(err.response.data)}` : "";
313
+ throw new Error(`Request failed (${status})${detail}`);
314
+ }
315
+ if (status && status >= 500) {
316
+ throw new Error(`Server error (${status}): ${context}`);
317
+ }
318
+ throw new Error(`Request failed: ${err.message}`);
319
+ }
320
+ throw err;
321
+ }
322
+ function authHeaders(creds) {
323
+ return { Authorization: `Bearer ${creds.token}` };
324
+ }
325
+ async function listSessions(config, creds) {
326
+ const allSessions = [];
327
+ let cursor;
328
+ while (true) {
329
+ const params = { limit: "100" };
330
+ if (cursor) params.cursor = cursor;
331
+ let data;
332
+ try {
333
+ const resp = await axios.get(`${config.serverUrl}/v2/sessions`, {
334
+ headers: authHeaders(creds),
335
+ params
336
+ });
337
+ data = resp.data;
338
+ } catch (err) {
339
+ handleApiError(err, "listing sessions");
340
+ }
341
+ allSessions.push(...data.sessions);
342
+ if (!data.nextCursor || data.sessions.length === 0) break;
343
+ cursor = data.nextCursor;
344
+ }
345
+ return allSessions.map((raw) => decryptSession(raw, creds));
346
+ }
347
+ async function listActiveSessions(config, creds) {
348
+ let data;
349
+ try {
350
+ const resp = await axios.get(`${config.serverUrl}/v2/sessions/active`, {
351
+ headers: authHeaders(creds)
352
+ });
353
+ data = resp.data;
354
+ } catch (err) {
355
+ handleApiError(err, "listing active sessions");
356
+ }
357
+ return data.sessions.map((raw) => decryptSession(raw, creds));
358
+ }
359
+ async function createSession(config, creds, opts) {
360
+ const sessionKey = getRandomBytes(32);
361
+ const encryptedKey = libsodiumEncryptForPublicKey(
362
+ sessionKey,
363
+ creds.contentKeyPair.publicKey
364
+ );
365
+ const withVersion = new Uint8Array(1 + encryptedKey.length);
366
+ withVersion[0] = 0;
367
+ withVersion.set(encryptedKey, 1);
368
+ const dataEncryptionKeyBase64 = encodeBase64(withVersion);
369
+ const encryptedMetadata = encryptWithDataKey(opts.metadata, sessionKey);
370
+ const metadataBase64 = encodeBase64(encryptedMetadata);
371
+ let data;
372
+ try {
373
+ const resp = await axios.post(
374
+ `${config.serverUrl}/v1/sessions`,
375
+ {
376
+ tag: opts.tag,
377
+ metadata: metadataBase64,
378
+ dataEncryptionKey: dataEncryptionKeyBase64
379
+ },
380
+ { headers: authHeaders(creds) }
381
+ );
382
+ data = resp.data;
383
+ } catch (err) {
384
+ handleApiError(err, "creating session");
385
+ }
386
+ const decrypted = decryptSession(data.session, creds);
387
+ return { ...decrypted, sessionKey: decrypted.encryption.key };
388
+ }
389
+ async function deleteSession(config, creds, sessionId) {
390
+ try {
391
+ await axios.delete(
392
+ `${config.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}`,
393
+ {
394
+ headers: authHeaders(creds)
395
+ }
396
+ );
397
+ } catch (err) {
398
+ handleApiError(err, `deleting session ${sessionId}`);
399
+ }
400
+ }
401
+ async function getSessionMessages(config, creds, sessionId, encryption) {
402
+ let data;
403
+ try {
404
+ const resp = await axios.get(
405
+ `${config.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}/messages`,
406
+ { headers: authHeaders(creds) }
407
+ );
408
+ data = resp.data;
409
+ } catch (err) {
410
+ handleApiError(err, `session ${sessionId} messages`);
411
+ }
412
+ return data.messages.map((msg) => ({
413
+ id: msg.id,
414
+ seq: msg.seq,
415
+ content: decryptField(msg.content.c, encryption),
416
+ localId: msg.localId ?? null,
417
+ createdAt: msg.createdAt,
418
+ updatedAt: msg.updatedAt
419
+ }));
420
+ }
421
+
422
+ class SessionClient extends node_events.EventEmitter {
423
+ sessionId;
424
+ encryptionKey;
425
+ encryptionVariant;
426
+ socket;
427
+ metadata = null;
428
+ metadataVersion = 0;
429
+ agentState = null;
430
+ agentStateVersion = 0;
431
+ aliveInterval = null;
432
+ constructor(opts) {
433
+ super();
434
+ this.sessionId = opts.sessionId;
435
+ this.encryptionKey = opts.encryptionKey;
436
+ this.encryptionVariant = opts.encryptionVariant;
437
+ if (opts.initialAgentState !== void 0) {
438
+ this.agentState = opts.initialAgentState;
439
+ }
440
+ this.on("error", () => {
441
+ });
442
+ this.socket = socket_ioClient.io(opts.serverUrl, {
443
+ auth: {
444
+ token: opts.token,
445
+ clientType: "session-scoped",
446
+ sessionId: opts.sessionId
447
+ },
448
+ path: "/v1/updates",
449
+ reconnection: true,
450
+ reconnectionAttempts: Infinity,
451
+ reconnectionDelay: 1e3,
452
+ reconnectionDelayMax: 5e3,
453
+ transports: ["websocket"],
454
+ autoConnect: false
455
+ });
456
+ this.socket.on("connect", () => {
457
+ this.emit("connected");
458
+ this.aliveInterval = setInterval(() => {
459
+ this.socket.emit("session-alive", {
460
+ sid: this.sessionId,
461
+ time: Date.now()
462
+ });
463
+ }, 2e4);
464
+ });
465
+ this.socket.on("disconnect", (reason) => {
466
+ if (this.aliveInterval !== null) {
467
+ clearInterval(this.aliveInterval);
468
+ this.aliveInterval = null;
469
+ }
470
+ this.emit("disconnected", reason);
471
+ });
472
+ this.socket.on("connect_error", (error) => {
473
+ this.emit("connect_error", error);
474
+ });
475
+ this.socket.on("update", (data) => {
476
+ try {
477
+ const body = data?.body;
478
+ if (!body) return;
479
+ if (body.t === "new-message" && body.message?.content?.t === "encrypted") {
480
+ const msg = body.message;
481
+ const decrypted = decrypt(
482
+ this.encryptionKey,
483
+ this.encryptionVariant,
484
+ decodeBase64(msg.content.c)
485
+ );
486
+ if (decrypted === null) return;
487
+ this.emit("message", {
488
+ id: msg.id,
489
+ seq: msg.seq,
490
+ content: decrypted,
491
+ localId: msg.localId,
492
+ createdAt: msg.createdAt,
493
+ updatedAt: msg.updatedAt
494
+ });
495
+ } else if (body.t === "update-session") {
496
+ if (body.metadata && body.metadata.version > this.metadataVersion) {
497
+ this.metadata = decrypt(
498
+ this.encryptionKey,
499
+ this.encryptionVariant,
500
+ decodeBase64(body.metadata.value)
501
+ );
502
+ this.metadataVersion = body.metadata.version;
503
+ }
504
+ if (body.agentState && body.agentState.version > this.agentStateVersion) {
505
+ this.agentState = body.agentState.value ? decrypt(
506
+ this.encryptionKey,
507
+ this.encryptionVariant,
508
+ decodeBase64(body.agentState.value)
509
+ ) : null;
510
+ this.agentStateVersion = body.agentState.version;
511
+ }
512
+ this.emit("state-change", {
513
+ metadata: this.metadata,
514
+ agentState: this.agentState
515
+ });
516
+ }
517
+ } catch (err) {
518
+ this.emit("error", err);
519
+ }
520
+ });
521
+ this.socket.connect();
522
+ }
523
+ sendMessage(text, meta) {
524
+ const content = {
525
+ role: "user",
526
+ content: {
527
+ type: "text",
528
+ text
529
+ },
530
+ meta: {
531
+ sentFrom: "happy-agent",
532
+ ...meta
533
+ }
534
+ };
535
+ const encrypted = encodeBase64(
536
+ encrypt(this.encryptionKey, this.encryptionVariant, content)
537
+ );
538
+ this.socket.emit("message", {
539
+ sid: this.sessionId,
540
+ message: encrypted
541
+ });
542
+ }
543
+ getMetadata() {
544
+ return this.metadata;
545
+ }
546
+ getAgentState() {
547
+ return this.agentState;
548
+ }
549
+ waitForConnect(timeoutMs = 1e4) {
550
+ return new Promise((resolve, reject) => {
551
+ if (this.socket.connected) {
552
+ resolve();
553
+ return;
554
+ }
555
+ const timeout = setTimeout(() => {
556
+ this.removeListener("connected", onConnect);
557
+ this.removeListener("connect_error", onError);
558
+ reject(new Error("Timeout waiting for socket connection"));
559
+ }, timeoutMs);
560
+ const onConnect = () => {
561
+ clearTimeout(timeout);
562
+ this.removeListener("connect_error", onError);
563
+ resolve();
564
+ };
565
+ const onError = (err) => {
566
+ clearTimeout(timeout);
567
+ this.removeListener("connected", onConnect);
568
+ reject(err);
569
+ };
570
+ this.once("connected", onConnect);
571
+ this.once("connect_error", onError);
572
+ });
573
+ }
574
+ waitForIdle(timeoutMs = 3e5) {
575
+ return new Promise((resolve, reject) => {
576
+ const checkIdle = () => {
577
+ const meta = this.metadata;
578
+ if (meta?.lifecycleState === "archived") {
579
+ return "archived";
580
+ }
581
+ const state = this.agentState;
582
+ if (!state) {
583
+ return false;
584
+ }
585
+ const controlledByUser = state.controlledByUser === true;
586
+ const requests = state.requests;
587
+ const hasRequests = requests != null && typeof requests === "object" && !Array.isArray(requests) && Object.keys(requests).length > 0;
588
+ return !controlledByUser && !hasRequests;
589
+ };
590
+ const cleanup = () => {
591
+ clearTimeout(timeout);
592
+ this.removeListener("state-change", onStateChange);
593
+ this.removeListener("disconnected", onDisconnect);
594
+ };
595
+ const result = checkIdle();
596
+ if (result === "archived") {
597
+ reject(new Error("Session is archived"));
598
+ return;
599
+ }
600
+ if (result === true) {
601
+ resolve();
602
+ return;
603
+ }
604
+ const timeout = setTimeout(() => {
605
+ cleanup();
606
+ reject(new Error("Timeout waiting for agent to become idle"));
607
+ }, timeoutMs);
608
+ const onStateChange = () => {
609
+ const r = checkIdle();
610
+ if (r === "archived") {
611
+ cleanup();
612
+ reject(new Error("Session is archived"));
613
+ } else if (r === true) {
614
+ cleanup();
615
+ resolve();
616
+ }
617
+ };
618
+ const onDisconnect = () => {
619
+ cleanup();
620
+ reject(
621
+ new Error(
622
+ "Socket disconnected while waiting for agent to become idle"
623
+ )
624
+ );
625
+ };
626
+ this.on("state-change", onStateChange);
627
+ this.on("disconnected", onDisconnect);
628
+ });
629
+ }
630
+ sendStop() {
631
+ this.socket.emit("session-end", {
632
+ sid: this.sessionId,
633
+ time: Date.now()
634
+ });
635
+ }
636
+ close() {
637
+ if (this.aliveInterval !== null) {
638
+ clearInterval(this.aliveInterval);
639
+ this.aliveInterval = null;
640
+ }
641
+ this.socket.close();
642
+ }
643
+ }
644
+
645
+ function formatTime(ts) {
646
+ if (!ts) return "-";
647
+ const date = new Date(ts);
648
+ const now = /* @__PURE__ */ new Date();
649
+ const diffMs = now.getTime() - date.getTime();
650
+ const diffMin = Math.floor(diffMs / 6e4);
651
+ if (diffMin < 1) return "just now";
652
+ if (diffMin < 60) return `${diffMin}m ago`;
653
+ const diffHr = Math.floor(diffMin / 60);
654
+ if (diffHr < 24) return `${diffHr}h ago`;
655
+ const diffDay = Math.floor(diffHr / 24);
656
+ return `${diffDay}d ago`;
657
+ }
658
+ function formatIsoTime(ts) {
659
+ if (!ts) return "-";
660
+ const date = new Date(ts);
661
+ if (Number.isNaN(date.getTime())) return "-";
662
+ return date.toISOString();
663
+ }
664
+ function formatLastActive(ts) {
665
+ const relative = formatTime(ts);
666
+ const absolute = formatIsoTime(ts);
667
+ if (absolute === "-") return relative;
668
+ return `${relative} (${absolute})`;
669
+ }
670
+ function toMarkdownInline(value) {
671
+ const escaped = value.replace(/`/g, "\\`");
672
+ return `\`${escaped}\``;
673
+ }
674
+ function normalizeCodeBlockText(value) {
675
+ const text = value.trim().length > 0 ? value : "(empty)";
676
+ return text.replace(/```/g, "``\\`");
677
+ }
678
+ function normalizeListValue(value) {
679
+ return value.replace(/\r?\n/g, " ").trim();
680
+ }
681
+ function toNonEmptyString(value) {
682
+ return typeof value === "string" && value.trim().length > 0 ? value : void 0;
683
+ }
684
+ function extractSessionSummary(meta) {
685
+ const direct = toNonEmptyString(meta.summary);
686
+ if (direct) return direct;
687
+ if (meta.summary != null && typeof meta.summary === "object") {
688
+ return toNonEmptyString(meta.summary.text);
689
+ }
690
+ return void 0;
691
+ }
692
+ function formatSessionTable(sessions) {
693
+ if (sessions.length === 0) {
694
+ return "## Sessions\n\n- Total: 0\n- Items: none";
695
+ }
696
+ const sections = sessions.map((s, index) => {
697
+ const meta = s.metadata ?? {};
698
+ const name = normalizeListValue(extractSessionSummary(meta) ?? toNonEmptyString(meta.tag) ?? "-");
699
+ const path = normalizeListValue(toNonEmptyString(meta.path) ?? "-");
700
+ const status = s.active ? "active" : "inactive";
701
+ const lastActive = normalizeListValue(formatLastActive(s.activeAt));
702
+ return [
703
+ `### Session ${index + 1}`,
704
+ `- ID: ${toMarkdownInline(s.id)}`,
705
+ `- Name: ${name}`,
706
+ `- Path: ${path}`,
707
+ `- Status: ${status}`,
708
+ `- Last Active: ${lastActive}`
709
+ ].join("\n");
710
+ });
711
+ return `## Sessions
712
+
713
+ - Total: ${sessions.length}
714
+
715
+ ${sections.join("\n\n")}`;
716
+ }
717
+ function formatSessionStatus(session) {
718
+ const meta = session.metadata ?? {};
719
+ const state = session.agentState ?? null;
720
+ const tag = toNonEmptyString(meta.tag);
721
+ const summary = extractSessionSummary(meta);
722
+ const path = toNonEmptyString(meta.path);
723
+ const host = toNonEmptyString(meta.host);
724
+ const lifecycleState = toNonEmptyString(meta.lifecycleState);
725
+ const lines = [
726
+ "## Session Status",
727
+ "",
728
+ `- Session ID: ${toMarkdownInline(session.id)}`
729
+ ];
730
+ if (tag) lines.push(`- Tag: ${tag}`);
731
+ if (summary) lines.push(`- Summary: ${summary}`);
732
+ if (path) lines.push(`- Path: ${path}`);
733
+ if (host) lines.push(`- Host: ${host}`);
734
+ if (lifecycleState) lines.push(`- Lifecycle: ${lifecycleState}`);
735
+ lines.push(`- Active: ${session.active ? "yes" : "no"}`);
736
+ lines.push(`- Last Active: ${formatLastActive(session.activeAt)}`);
737
+ if (state) {
738
+ const requests = state.requests != null && typeof state.requests === "object" ? Object.keys(state.requests).length : 0;
739
+ const busy = state.controlledByUser === true || requests > 0;
740
+ const agentStatus = busy ? "busy" : "idle";
741
+ lines.push(`- Agent: ${agentStatus}`);
742
+ if (requests > 0) {
743
+ lines.push(`- Pending Requests: ${requests}`);
744
+ }
745
+ } else {
746
+ lines.push("- Agent: no state");
747
+ }
748
+ return lines.join("\n");
749
+ }
750
+ function formatMessageHistory(messages) {
751
+ if (messages.length === 0) {
752
+ return "## Message History\n\n- Count: 0\n- Items: none";
753
+ }
754
+ const sections = messages.map((msg, index) => {
755
+ const content = msg.content;
756
+ const role = content?.role ?? "unknown";
757
+ const timestamp = formatIsoTime(msg.createdAt);
758
+ let text;
759
+ if (content?.content && typeof content.content === "object" && content.content.text) {
760
+ text = String(content.content.text);
761
+ } else if (content?.content && typeof content.content === "string") {
762
+ text = content.content;
763
+ } else {
764
+ text = JSON.stringify(content);
765
+ }
766
+ return [
767
+ `### Message ${index + 1}`,
768
+ `- ID: ${toMarkdownInline(msg.id)}`,
769
+ `- Time: ${timestamp}`,
770
+ `- Role: ${role}`,
771
+ "- Text:",
772
+ "```text",
773
+ normalizeCodeBlockText(text),
774
+ "```"
775
+ ].join("\n");
776
+ });
777
+ return `## Message History
778
+
779
+ - Count: ${messages.length}
780
+
781
+ ${sections.join("\n\n")}`;
782
+ }
783
+ function formatJson(data) {
784
+ return JSON.stringify(data, (key, value) => {
785
+ if (key === "encryption" || key === "dataEncryptionKey") return void 0;
786
+ if (value instanceof Uint8Array) {
787
+ return Buffer.from(value).toString("base64");
788
+ }
789
+ return value;
790
+ }, 2);
791
+ }
792
+
793
+ async function resolveSession(config, creds, sessionId) {
794
+ if (!sessionId || sessionId.trim().length === 0) {
795
+ throw new Error("Session ID is required");
796
+ }
797
+ const sessions = await listSessions(config, creds);
798
+ const matches = sessions.filter((s) => s.id.startsWith(sessionId));
799
+ if (matches.length === 0) {
800
+ throw new Error(`No session found matching "${sessionId}"`);
801
+ }
802
+ if (matches.length > 1) {
803
+ throw new Error(
804
+ `Ambiguous session ID "${sessionId}" matches ${matches.length} sessions. Be more specific.`
805
+ );
806
+ }
807
+ return matches[0];
808
+ }
809
+ function createClient(session, creds, config) {
810
+ return new SessionClient({
811
+ sessionId: session.id,
812
+ encryptionKey: session.encryption.key,
813
+ encryptionVariant: session.encryption.variant,
814
+ token: creds.token,
815
+ serverUrl: config.serverUrl,
816
+ initialAgentState: session.agentState ?? null
817
+ });
818
+ }
819
+ const program = new commander.Command();
820
+ program.name("happy-agent").description("CLI client for controlling Happy Coder agents remotely").version(version);
821
+ program.command("auth").description("Manage authentication").addCommand(
822
+ new commander.Command("login").description("Authenticate via QR code").action(async () => {
823
+ const config = loadConfig();
824
+ await authLogin(config);
825
+ })
826
+ ).addCommand(
827
+ new commander.Command("logout").description("Clear stored credentials").action(async () => {
828
+ const config = loadConfig();
829
+ await authLogout(config);
830
+ })
831
+ ).addCommand(
832
+ new commander.Command("status").description("Show authentication status").action(async () => {
833
+ const config = loadConfig();
834
+ await authStatus(config);
835
+ })
836
+ );
837
+ program.command("list").description("List all sessions").option("--active", "Show only active sessions").option("--json", "Output as JSON").action(async (opts) => {
838
+ const config = loadConfig();
839
+ const creds = requireCredentials(config);
840
+ const sessions = opts.active ? await listActiveSessions(config, creds) : await listSessions(config, creds);
841
+ if (opts.json) {
842
+ console.log(formatJson(sessions));
843
+ } else {
844
+ console.log(formatSessionTable(sessions));
845
+ }
846
+ });
847
+ program.command("status").description("Get live session state").argument("<session-id>", "Session ID or prefix").option("--json", "Output as JSON").action(async (sessionId, opts) => {
848
+ const config = loadConfig();
849
+ const creds = requireCredentials(config);
850
+ const session = await resolveSession(config, creds, sessionId);
851
+ const client = createClient(session, creds, config);
852
+ let liveData = false;
853
+ try {
854
+ await new Promise((resolve) => {
855
+ let resolved = false;
856
+ const done = () => {
857
+ if (resolved) return;
858
+ resolved = true;
859
+ clearTimeout(timeout);
860
+ client.removeAllListeners("state-change");
861
+ client.removeAllListeners("connect_error");
862
+ resolve();
863
+ };
864
+ const timeout = setTimeout(done, 3e3);
865
+ client.once(
866
+ "state-change",
867
+ (data) => {
868
+ session.metadata = data.metadata ?? session.metadata;
869
+ session.agentState = data.agentState ?? session.agentState;
870
+ liveData = true;
871
+ done();
872
+ }
873
+ );
874
+ client.once("connect_error", () => {
875
+ done();
876
+ });
877
+ });
878
+ } finally {
879
+ client.close();
880
+ }
881
+ if (opts.json) {
882
+ console.log(formatJson(session));
883
+ } else {
884
+ if (!liveData) {
885
+ console.log("> Note: showing cached data (could not get live status).");
886
+ }
887
+ console.log(formatSessionStatus(session));
888
+ }
889
+ });
890
+ program.command("create").description("Create a new session").requiredOption("--tag <tag>", "Session tag").option("--path <path>", "Working directory path").option("--json", "Output as JSON").action(async (opts) => {
891
+ const config = loadConfig();
892
+ const creds = requireCredentials(config);
893
+ const metadata = {
894
+ tag: opts.tag,
895
+ path: opts.path ?? process.cwd(),
896
+ host: node_os.hostname()
897
+ };
898
+ const session = await createSession(config, creds, {
899
+ tag: opts.tag,
900
+ metadata
901
+ });
902
+ if (opts.json) {
903
+ console.log(formatJson(session));
904
+ } else {
905
+ console.log(
906
+ ["## Session Created", "", `- Session ID: \`${session.id}\``].join(
907
+ "\n"
908
+ )
909
+ );
910
+ }
911
+ });
912
+ program.command("send").description("Send a message to a session").argument("<session-id>", "Session ID or prefix").argument("<message>", "Message text").option("--wait", "Wait for agent to become idle").option(
913
+ "--timeout <seconds>",
914
+ "Timeout in seconds when using --wait",
915
+ (v) => {
916
+ const n = parseInt(v, 10);
917
+ if (isNaN(n) || n <= 0)
918
+ throw new Error("--timeout must be a positive integer");
919
+ return n;
920
+ },
921
+ 300
922
+ ).option("--json", "Output as JSON").action(
923
+ async (sessionId, message, opts) => {
924
+ const config = loadConfig();
925
+ const creds = requireCredentials(config);
926
+ const session = await resolveSession(config, creds, sessionId);
927
+ const client = createClient(session, creds, config);
928
+ try {
929
+ await client.waitForConnect();
930
+ client.sendMessage(message);
931
+ if (opts.wait) {
932
+ await client.waitForIdle(opts.timeout * 1e3);
933
+ } else {
934
+ await new Promise((resolve) => setTimeout(resolve, 500));
935
+ }
936
+ } finally {
937
+ client.close();
938
+ }
939
+ if (opts.json) {
940
+ console.log(formatJson({ sessionId: session.id, message, sent: true }));
941
+ } else {
942
+ console.log(
943
+ [
944
+ "## Message Sent",
945
+ "",
946
+ `- Session ID: \`${session.id}\``,
947
+ `- Waited For Idle: ${opts.wait ? "yes" : "no"}`
948
+ ].join("\n")
949
+ );
950
+ }
951
+ }
952
+ );
953
+ program.command("history").description("Read message history").argument("<session-id>", "Session ID or prefix").option("--limit <n>", "Limit number of messages", (v) => {
954
+ const n = parseInt(v, 10);
955
+ if (isNaN(n) || n <= 0)
956
+ throw new Error("--limit must be a positive integer");
957
+ return n;
958
+ }).option("--json", "Output as JSON").action(
959
+ async (sessionId, opts) => {
960
+ const config = loadConfig();
961
+ const creds = requireCredentials(config);
962
+ const session = await resolveSession(config, creds, sessionId);
963
+ let messages = await getSessionMessages(
964
+ config,
965
+ creds,
966
+ session.id,
967
+ session.encryption
968
+ );
969
+ messages.sort((a, b) => a.createdAt - b.createdAt);
970
+ if (opts.limit && opts.limit > 0) {
971
+ messages = messages.slice(-opts.limit);
972
+ }
973
+ if (opts.json) {
974
+ console.log(formatJson(messages));
975
+ } else {
976
+ console.log(formatMessageHistory(messages));
977
+ }
978
+ }
979
+ );
980
+ program.command("stop").description("Stop a session").argument("<session-id>", "Session ID or prefix").action(async (sessionId) => {
981
+ const config = loadConfig();
982
+ const creds = requireCredentials(config);
983
+ const session = await resolveSession(config, creds, sessionId);
984
+ const client = createClient(session, creds, config);
985
+ try {
986
+ await client.waitForConnect();
987
+ client.sendStop();
988
+ await new Promise((resolve) => setTimeout(resolve, 500));
989
+ } finally {
990
+ client.close();
991
+ }
992
+ console.log(
993
+ ["## Session Stopped", "", `- Session ID: \`${session.id}\``].join("\n")
994
+ );
995
+ });
996
+ program.command("delete").description("Delete a session permanently").argument("<session-id>", "Session ID or prefix").action(async (sessionId) => {
997
+ const config = loadConfig();
998
+ const creds = requireCredentials(config);
999
+ const session = await resolveSession(config, creds, sessionId);
1000
+ await deleteSession(config, creds, session.id);
1001
+ console.log(
1002
+ ["## Session Deleted", "", `- Session ID: \`${session.id}\``].join("\n")
1003
+ );
1004
+ });
1005
+ program.command("wait").description("Wait for agent to become idle").argument("<session-id>", "Session ID or prefix").option(
1006
+ "--timeout <seconds>",
1007
+ "Timeout in seconds",
1008
+ (v) => {
1009
+ const n = parseInt(v, 10);
1010
+ if (isNaN(n) || n <= 0)
1011
+ throw new Error("--timeout must be a positive integer");
1012
+ return n;
1013
+ },
1014
+ 300
1015
+ ).action(async (sessionId, opts) => {
1016
+ const config = loadConfig();
1017
+ const creds = requireCredentials(config);
1018
+ const session = await resolveSession(config, creds, sessionId);
1019
+ const client = createClient(session, creds, config);
1020
+ try {
1021
+ await client.waitForConnect();
1022
+ await client.waitForIdle(opts.timeout * 1e3);
1023
+ console.log(
1024
+ ["## Session Idle", "", `- Session ID: \`${session.id}\``].join("\n")
1025
+ );
1026
+ } catch (err) {
1027
+ const msg = err instanceof Error ? err.message : String(err);
1028
+ console.error(msg);
1029
+ process.exitCode = 1;
1030
+ } finally {
1031
+ client.close();
1032
+ }
1033
+ });
1034
+ program.parseAsync(process.argv).catch((err) => {
1035
+ console.error(err instanceof Error ? err.message : String(err));
1036
+ process.exitCode = 1;
1037
+ });