@kmmao/happy-agent 0.2.3 → 0.3.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.mjs CHANGED
@@ -1,21 +1,29 @@
1
1
  import { Command } from 'commander';
2
2
  import { homedir, hostname } from 'node:os';
3
3
  import { join, dirname } from 'node:path';
4
- import { mkdirSync, writeFileSync, unlinkSync, readFileSync } from 'node:fs';
4
+ import { mkdirSync, writeFileSync, unlinkSync, readFileSync, appendFileSync } from 'node:fs';
5
5
  import { randomBytes, createCipheriv, createHash, createDecipheriv, createHmac } from 'node:crypto';
6
6
  import tweetnacl from 'tweetnacl';
7
7
  import axios, { AxiosError } from 'axios';
8
8
  import qrcode from 'qrcode-terminal';
9
9
  import { EventEmitter } from 'node:events';
10
10
  import { io } from 'socket.io-client';
11
+ import { exec } from 'child_process';
12
+ import { promisify } from 'util';
13
+ import { readFile, mkdir, writeFile, readdir, stat } from 'fs/promises';
14
+ import { createHash as createHash$1 } from 'crypto';
15
+ import { join as join$1, resolve } from 'path';
16
+ import { realpathSync } from 'fs';
17
+ import { tmpdir } from 'os';
11
18
 
12
- var version = "0.2.3";
19
+ var version = "0.3.0";
13
20
 
14
21
  function loadConfig() {
15
22
  const serverUrl = (process.env.HAPPY_SERVER_URL ?? "https://happyserve.xycloud.info").replace(/\/+$/, "");
23
+ const webappUrl = (process.env.HAPPY_WEBAPP_URL ?? "https://happyapp.xycloud.info").replace(/\/+$/, "");
16
24
  const homeDir = process.env.HAPPY_HOME_DIR ?? join(homedir(), ".happy");
17
25
  const credentialPath = join(homeDir, "agent.key");
18
- return { serverUrl, homeDir, credentialPath };
26
+ return { serverUrl, webappUrl, homeDir, credentialPath };
19
27
  }
20
28
 
21
29
  function encodeBase64(buffer) {
@@ -182,7 +190,7 @@ function requireCredentials(config) {
182
190
 
183
191
  const POLL_INTERVAL_MS = 1e3;
184
192
  const AUTH_TIMEOUT_MS = 12e4;
185
- async function authLogin(config) {
193
+ async function authLogin(config, opts) {
186
194
  const seed = getRandomBytes(32);
187
195
  const keypair = tweetnacl.box.keyPair.fromSecretKey(seed);
188
196
  const publicKeyBase64 = encodeBase64(keypair.publicKey);
@@ -196,15 +204,25 @@ async function authLogin(config) {
196
204
  }
197
205
  throw err;
198
206
  }
199
- const qrData = `happy:///account?${encodeBase64Url(keypair.publicKey)}`;
200
- console.log("");
201
- qrcode.generate(qrData, { small: true }, (code) => {
202
- console.log(code);
203
- });
204
- console.log("## Authentication");
205
- console.log("- Action: Scan this QR code with the Happy app");
206
- console.log("- Path: Settings -> Account -> Link New Device");
207
- console.log("");
207
+ if (opts?.web) {
208
+ const publicKeyBase64Url = encodeBase64Url(keypair.publicKey);
209
+ const webUrl = `${config.webappUrl}/terminal/connect#key=${publicKeyBase64Url}`;
210
+ console.log("");
211
+ console.log("## Authentication (Web)");
212
+ console.log(`- Open this URL in your browser: ${webUrl}`);
213
+ console.log("- Then sign in with your Happy account to complete linking.");
214
+ console.log("");
215
+ } else {
216
+ const qrData = `happy:///account?${encodeBase64Url(keypair.publicKey)}`;
217
+ console.log("");
218
+ qrcode.generate(qrData, { small: true }, (code) => {
219
+ console.log(code);
220
+ });
221
+ console.log("## Authentication");
222
+ console.log("- Action: Scan this QR code with the Happy app");
223
+ console.log("- Path: Settings -> Account -> Link New Device");
224
+ console.log("");
225
+ }
208
226
  const startTime = Date.now();
209
227
  while (Date.now() - startTime < AUTH_TIMEOUT_MS) {
210
228
  await sleep(POLL_INTERVAL_MS);
@@ -388,9 +406,7 @@ async function deleteSession(config, creds, sessionId) {
388
406
  try {
389
407
  await axios.delete(
390
408
  `${config.serverUrl}/v1/sessions/${encodeURIComponent(sessionId)}`,
391
- {
392
- headers: authHeaders(creds)
393
- }
409
+ { headers: authHeaders(creds) }
394
410
  );
395
411
  } catch (err) {
396
412
  handleApiError(err, `deleting session ${sessionId}`);
@@ -416,9 +432,602 @@ async function getSessionMessages(config, creds, sessionId, encryption) {
416
432
  updatedAt: msg.updatedAt
417
433
  }));
418
434
  }
435
+ async function fetchMessagesAfterSeq(config, creds, sessionId, encryption, afterSeq, limit = 100) {
436
+ let data;
437
+ try {
438
+ const resp = await axios.get(
439
+ `${config.serverUrl}/v3/sessions/${encodeURIComponent(sessionId)}/messages`,
440
+ {
441
+ params: { after_seq: afterSeq, limit },
442
+ headers: authHeaders(creds),
443
+ timeout: 6e4
444
+ }
445
+ );
446
+ data = resp.data;
447
+ } catch (err) {
448
+ handleApiError(err, `fetching v3 messages for session ${sessionId}`);
449
+ }
450
+ const messages = data.messages.map((msg) => {
451
+ const content = decryptField(msg.content.c, encryption);
452
+ if (content === null) return null;
453
+ return {
454
+ id: msg.id,
455
+ seq: msg.seq,
456
+ content,
457
+ localId: msg.localId ?? null,
458
+ createdAt: msg.createdAt,
459
+ updatedAt: msg.updatedAt
460
+ };
461
+ }).filter((m) => m !== null);
462
+ return { messages, hasMore: data.hasMore };
463
+ }
464
+ async function sendMessagesBatch(config, creds, sessionId, encryption, contents) {
465
+ const messages = contents.map((content) => ({
466
+ content: encodeBase64(
467
+ encrypt(encryption.key, encryption.variant, content)
468
+ )
469
+ }));
470
+ let data;
471
+ try {
472
+ const resp = await axios.post(
473
+ `${config.serverUrl}/v3/sessions/${encodeURIComponent(sessionId)}/messages`,
474
+ { messages },
475
+ { headers: authHeaders(creds), timeout: 6e4 }
476
+ );
477
+ data = resp.data;
478
+ } catch (err) {
479
+ handleApiError(err, `sending v3 messages to session ${sessionId}`);
480
+ }
481
+ return data;
482
+ }
483
+ async function getOrCreateMachine(config, creds, metadata) {
484
+ const sessionKey = getRandomBytes(32);
485
+ const encryptedKey = libsodiumEncryptForPublicKey(
486
+ sessionKey,
487
+ creds.contentKeyPair.publicKey
488
+ );
489
+ const withVersion = new Uint8Array(1 + encryptedKey.length);
490
+ withVersion[0] = 0;
491
+ withVersion.set(encryptedKey, 1);
492
+ const encryptedMetadata = encryptWithDataKey(metadata, sessionKey);
493
+ let data;
494
+ try {
495
+ const resp = await axios.post(
496
+ `${config.serverUrl}/v2/machines`,
497
+ {
498
+ metadata: encodeBase64(encryptedMetadata),
499
+ dataEncryptionKey: encodeBase64(withVersion)
500
+ },
501
+ { headers: authHeaders(creds) }
502
+ );
503
+ data = resp.data;
504
+ } catch (err) {
505
+ handleApiError(err, "registering machine");
506
+ }
507
+ const raw = data.machine;
508
+ let encKey;
509
+ let encVariant;
510
+ if (raw.dataEncryptionKey) {
511
+ const encrypted = decodeBase64(raw.dataEncryptionKey);
512
+ const bundle = encrypted.slice(1);
513
+ const key = decryptBoxBundle(bundle, creds.contentKeyPair.secretKey);
514
+ if (!key) throw new Error("Failed to decrypt machine encryption key");
515
+ encKey = key;
516
+ encVariant = "dataKey";
517
+ } else {
518
+ encKey = creds.secret;
519
+ encVariant = "legacy";
520
+ }
521
+ return {
522
+ id: raw.id,
523
+ encryptionKey: encKey,
524
+ encryptionVariant: encVariant,
525
+ metadata: decryptField(raw.metadata, { key: encKey, variant: encVariant }) ?? metadata,
526
+ metadataVersion: raw.metadataVersion,
527
+ daemonState: raw.daemonState ? decryptField(raw.daemonState, { key: encKey, variant: encVariant }) : null,
528
+ daemonStateVersion: raw.daemonStateVersion
529
+ };
530
+ }
531
+ async function listMachines(config, creds) {
532
+ let data;
533
+ try {
534
+ const resp = await axios.get(`${config.serverUrl}/v2/machines`, {
535
+ headers: authHeaders(creds)
536
+ });
537
+ data = resp.data;
538
+ } catch (err) {
539
+ handleApiError(err, "listing machines");
540
+ }
541
+ return data.machines;
542
+ }
543
+
544
+ function timestamp() {
545
+ return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", {
546
+ hour12: false,
547
+ hour: "2-digit",
548
+ minute: "2-digit",
549
+ second: "2-digit",
550
+ fractionalSecondDigits: 3
551
+ });
552
+ }
553
+ function filenameTimestamp() {
554
+ return (/* @__PURE__ */ new Date()).toLocaleString("sv-SE", {
555
+ year: "numeric",
556
+ month: "2-digit",
557
+ day: "2-digit",
558
+ hour: "2-digit",
559
+ minute: "2-digit",
560
+ second: "2-digit"
561
+ }).replace(/[: ]/g, "-").replace(/,/g, "");
562
+ }
563
+ function resolveLogPath() {
564
+ const config = loadConfig();
565
+ const logsDir = join(config.homeDir, "logs");
566
+ mkdirSync(logsDir, { recursive: true });
567
+ return join(logsDir, `agent-${filenameTimestamp()}-pid-${process.pid}.log`);
568
+ }
569
+ class Logger {
570
+ logFilePath;
571
+ constructor() {
572
+ this.logFilePath = resolveLogPath();
573
+ }
574
+ debug(message, ...args) {
575
+ this.write("DEBUG", message, ...args);
576
+ }
577
+ warn(message, ...args) {
578
+ this.write("WARN", message, ...args);
579
+ }
580
+ error(message, ...args) {
581
+ this.write("ERROR", message, ...args);
582
+ }
583
+ write(level, message, ...args) {
584
+ const extra = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
585
+ const line = `[${timestamp()}] [${level}] ${message}${extra ? " " + extra : ""}
586
+ `;
587
+ try {
588
+ appendFileSync(this.logFilePath, line);
589
+ } catch {
590
+ }
591
+ }
592
+ }
593
+ const logger = new Logger();
594
+
595
+ async function withBackoff(fn, options) {
596
+ const maxRetries = options?.maxRetries ?? 3;
597
+ const baseDelay = options?.baseDelayMs ?? 200;
598
+ const label = options?.label ?? "operation";
599
+ let lastError;
600
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
601
+ try {
602
+ return await fn();
603
+ } catch (error) {
604
+ lastError = error;
605
+ if (attempt < maxRetries) {
606
+ const delay = baseDelay * 2 ** attempt;
607
+ logger.debug(`[backoff] ${label} attempt ${attempt + 1} failed, retrying in ${delay}ms`);
608
+ await new Promise((resolve) => setTimeout(resolve, delay));
609
+ }
610
+ }
611
+ }
612
+ throw lastError;
613
+ }
614
+
615
+ class RpcHandlerManager {
616
+ handlers = /* @__PURE__ */ new Map();
617
+ scopePrefix;
618
+ encryptionKey;
619
+ encryptionVariant;
620
+ logger;
621
+ socket = null;
622
+ reregisterInterval = null;
623
+ constructor(config) {
624
+ this.scopePrefix = config.scopePrefix;
625
+ this.encryptionKey = config.encryptionKey;
626
+ this.encryptionVariant = config.encryptionVariant;
627
+ this.logger = config.logger ?? ((msg, data) => logger.debug(msg, data));
628
+ }
629
+ /**
630
+ * Register an RPC handler for a specific method
631
+ */
632
+ registerHandler(method, handler) {
633
+ const prefixedMethod = this.getPrefixedMethod(method);
634
+ this.handlers.set(prefixedMethod, handler);
635
+ if (this.socket) {
636
+ this.emitRegisterWithRetry(this.socket, prefixedMethod);
637
+ }
638
+ }
639
+ /**
640
+ * Handle an incoming RPC request
641
+ */
642
+ async handleRequest(request) {
643
+ try {
644
+ const handler = this.handlers.get(request.method);
645
+ if (!handler) {
646
+ this.logger("[RPC] [ERROR] Method not found", {
647
+ method: request.method
648
+ });
649
+ const errorResponse = { error: "Method not found" };
650
+ return encodeBase64(
651
+ encrypt(this.encryptionKey, this.encryptionVariant, errorResponse)
652
+ );
653
+ }
654
+ const decryptedParams = decrypt(
655
+ this.encryptionKey,
656
+ this.encryptionVariant,
657
+ decodeBase64(request.params)
658
+ );
659
+ this.logger("[RPC] Calling handler", { method: request.method });
660
+ const result = await handler(decryptedParams);
661
+ this.logger("[RPC] Handler returned", {
662
+ method: request.method,
663
+ hasResult: result !== void 0
664
+ });
665
+ const encryptedResponse = encodeBase64(
666
+ encrypt(this.encryptionKey, this.encryptionVariant, result)
667
+ );
668
+ return encryptedResponse;
669
+ } catch (error) {
670
+ this.logger("[RPC] [ERROR] Error handling request", { error });
671
+ const errorResponse = {
672
+ error: error instanceof Error ? error.message : "Unknown error"
673
+ };
674
+ return encodeBase64(
675
+ encrypt(this.encryptionKey, this.encryptionVariant, errorResponse)
676
+ );
677
+ }
678
+ }
679
+ onSocketConnect(socket) {
680
+ this.socket = socket;
681
+ this.registerAllHandlers(socket);
682
+ this.startReregisterInterval();
683
+ }
684
+ onSocketDisconnect() {
685
+ this.socket = null;
686
+ this.stopReregisterInterval();
687
+ }
688
+ getHandlerCount() {
689
+ return this.handlers.size;
690
+ }
691
+ hasHandler(method) {
692
+ return this.handlers.has(this.getPrefixedMethod(method));
693
+ }
694
+ clearHandlers() {
695
+ this.handlers.clear();
696
+ this.logger("Cleared all RPC handlers");
697
+ }
698
+ // -----------------------------------------------------------------------
699
+ // Private
700
+ // -----------------------------------------------------------------------
701
+ /**
702
+ * Register a single method with ack + retry.
703
+ * Falls back to fire-and-forget emit after all retries exhausted.
704
+ */
705
+ emitRegisterWithRetry(socket, method, maxRetries = 3) {
706
+ const attempt = (remaining) => {
707
+ if (this.socket !== socket) return;
708
+ socket.timeout(5e3).emit(
709
+ "rpc-register",
710
+ { method },
711
+ (err, ackResponse) => {
712
+ if (this.socket !== socket) return;
713
+ if (err && remaining > 0) {
714
+ this.logger("[RPC] rpc-register ack timeout, retrying", {
715
+ method,
716
+ remaining
717
+ });
718
+ setTimeout(() => attempt(remaining - 1), 1e3);
719
+ } else if (err) {
720
+ this.logger(
721
+ "[RPC] [WARN] rpc-register failed after retries, falling back to emit",
722
+ { method }
723
+ );
724
+ socket.emit("rpc-register", { method });
725
+ } else if (!ackResponse?.ok) {
726
+ this.logger("[RPC] [WARN] rpc-register rejected by server", {
727
+ method,
728
+ error: ackResponse?.error
729
+ });
730
+ }
731
+ }
732
+ );
733
+ };
734
+ attempt(maxRetries);
735
+ }
736
+ registerAllHandlers(socket) {
737
+ for (const [prefixedMethod] of this.handlers) {
738
+ this.emitRegisterWithRetry(socket, prefixedMethod);
739
+ }
740
+ }
741
+ /**
742
+ * Periodic re-registration every 60s as a safety net.
743
+ */
744
+ startReregisterInterval() {
745
+ this.stopReregisterInterval();
746
+ this.reregisterInterval = setInterval(() => {
747
+ if (this.socket && this.handlers.size > 0) {
748
+ this.registerAllHandlers(this.socket);
749
+ }
750
+ }, 6e4);
751
+ }
752
+ stopReregisterInterval() {
753
+ if (this.reregisterInterval) {
754
+ clearInterval(this.reregisterInterval);
755
+ this.reregisterInterval = null;
756
+ }
757
+ }
758
+ getPrefixedMethod(method) {
759
+ return `${this.scopePrefix}:${method}`;
760
+ }
761
+ }
762
+ function createRpcHandlerManager(config) {
763
+ return new RpcHandlerManager(config);
764
+ }
765
+
766
+ const execAsync = promisify(exec);
767
+ const UPLOAD_TEMP_DIR = join$1(tmpdir(), "happy", "uploads");
768
+ const MAX_WRITE_SIZE = 10 * 1024 * 1024;
769
+ function validatePath(targetPath, workingDirectory, additionalAllowedDirs) {
770
+ const resolvedTarget = resolve(workingDirectory, targetPath);
771
+ let realTarget;
772
+ try {
773
+ realTarget = realpathSync(resolvedTarget);
774
+ } catch {
775
+ const parentDir = resolve(resolvedTarget, "..");
776
+ try {
777
+ realTarget = realpathSync(parentDir) + "/" + resolvedTarget.split("/").pop();
778
+ } catch {
779
+ realTarget = resolvedTarget;
780
+ }
781
+ }
782
+ const allowedDirs = [
783
+ workingDirectory,
784
+ ...additionalAllowedDirs ?? []
785
+ ].map((d) => {
786
+ const resolved = resolve(d);
787
+ try {
788
+ return realpathSync(resolved);
789
+ } catch {
790
+ return resolved;
791
+ }
792
+ });
793
+ for (const dir of allowedDirs) {
794
+ if (realTarget.startsWith(dir + "/") || realTarget === dir) {
795
+ return { valid: true };
796
+ }
797
+ }
798
+ return {
799
+ valid: false,
800
+ error: `Access denied: Path '${targetPath}' is outside the allowed directories`
801
+ };
802
+ }
803
+ const BLOCKED_BASH_PATTERNS = [
804
+ { pattern: /\bprintenv\b/i, reason: "printenv is blocked for security" },
805
+ {
806
+ pattern: /\benv\b(?:\s|$|;|\|)/i,
807
+ reason: "env command is blocked for security"
808
+ },
809
+ {
810
+ pattern: /\bset\b\s*(?:$|;|\|)/i,
811
+ reason: "set (list env) is blocked for security"
812
+ },
813
+ {
814
+ pattern: /\bexport\s+-p\b/i,
815
+ reason: "export -p is blocked for security"
816
+ },
817
+ {
818
+ pattern: /\bcompgen\s+-e\b/i,
819
+ reason: "compgen -e is blocked for security"
820
+ },
821
+ {
822
+ pattern: /\bdeclare\s+-x\b/i,
823
+ reason: "declare -x is blocked for security"
824
+ },
825
+ {
826
+ pattern: /\/proc\/[^/]*\/environ/i,
827
+ reason: "reading /proc/environ is blocked for security"
828
+ },
829
+ {
830
+ pattern: /\$\{?\s*(ANTHROPIC_AUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_BASE_URL|OPENAI_API_KEY|OPENAI_BASE_URL|DATABASE_URL|REDIS_URL|JWT_SECRET|ENCRYPTION_KEY|AWS_SECRET_ACCESS_KEY|GOOGLE_API_KEY|GEMINI_API_KEY|TOGETHER_API_KEY|GITHUB_CLIENT_SECRET|CLAUDE_CODE_OAUTH_TOKEN)\b/i,
831
+ reason: "accessing sensitive environment variables is blocked"
832
+ },
833
+ {
834
+ pattern: /\.(env|env\.local|env\.prod|env\.production|env\.dev)\b/i,
835
+ reason: "reading .env files is blocked for security"
836
+ },
837
+ {
838
+ pattern: /\.aws\/credentials/i,
839
+ reason: "reading AWS credentials is blocked for security"
840
+ },
841
+ {
842
+ pattern: /\.netrc/i,
843
+ reason: "reading .netrc is blocked for security"
844
+ }
845
+ ];
846
+ function checkBlockedBashCommand(command) {
847
+ for (const { pattern, reason } of BLOCKED_BASH_PATTERNS) {
848
+ if (pattern.test(command)) {
849
+ return reason;
850
+ }
851
+ }
852
+ return null;
853
+ }
854
+ function registerAgentHandlers(rpcHandlerManager, workingDirectory, sessionId) {
855
+ const safeSessionId = sessionId.replace(/[^a-zA-Z0-9-]/g, "");
856
+ rpcHandlerManager.registerHandler(
857
+ "bash",
858
+ async (data) => {
859
+ logger.debug("Shell command request:", data.command);
860
+ const blockedReason = checkBlockedBashCommand(data.command);
861
+ if (blockedReason) {
862
+ logger.warn(
863
+ `[SECURITY] Blocked bash RPC command: ${blockedReason}`,
864
+ { command: data.command }
865
+ );
866
+ return { success: false, error: blockedReason };
867
+ }
868
+ if (data.cwd && data.cwd !== "/") {
869
+ const validation = validatePath(data.cwd, workingDirectory);
870
+ if (!validation.valid) {
871
+ return { success: false, error: validation.error };
872
+ }
873
+ }
874
+ try {
875
+ const options = {
876
+ cwd: data.cwd === "/" ? void 0 : data.cwd,
877
+ timeout: data.timeout ?? 3e4
878
+ };
879
+ const { stdout, stderr } = await execAsync(data.command, options);
880
+ return {
881
+ success: true,
882
+ stdout: stdout?.toString() ?? "",
883
+ stderr: stderr?.toString() ?? "",
884
+ exitCode: 0
885
+ };
886
+ } catch (error) {
887
+ const execError = error;
888
+ if (execError.code === "ETIMEDOUT" || execError.killed) {
889
+ return {
890
+ success: false,
891
+ stdout: execError.stdout ?? "",
892
+ stderr: execError.stderr ?? "",
893
+ exitCode: typeof execError.code === "number" ? execError.code : -1,
894
+ error: "Command timed out"
895
+ };
896
+ }
897
+ return {
898
+ success: false,
899
+ stdout: execError.stdout?.toString() ?? "",
900
+ stderr: execError.stderr?.toString() ?? execError.message ?? "Command failed",
901
+ exitCode: typeof execError.code === "number" ? execError.code : 1,
902
+ error: execError.message ?? "Command failed"
903
+ };
904
+ }
905
+ }
906
+ );
907
+ rpcHandlerManager.registerHandler(
908
+ "readFile",
909
+ async (data) => {
910
+ logger.debug("Read file request:", data.path);
911
+ const sessionUploadDir = join$1(UPLOAD_TEMP_DIR, safeSessionId);
912
+ const validation = validatePath(data.path, workingDirectory, [
913
+ sessionUploadDir
914
+ ]);
915
+ if (!validation.valid) {
916
+ return { success: false, error: validation.error };
917
+ }
918
+ try {
919
+ const resolvedPath = resolve(workingDirectory, data.path);
920
+ const buffer = await readFile(resolvedPath);
921
+ return { success: true, content: buffer.toString("base64") };
922
+ } catch (error) {
923
+ logger.debug("Failed to read file:", error);
924
+ return {
925
+ success: false,
926
+ error: error instanceof Error ? error.message : "Failed to read file"
927
+ };
928
+ }
929
+ }
930
+ );
931
+ rpcHandlerManager.registerHandler(
932
+ "writeFile",
933
+ async (data) => {
934
+ logger.debug("Write file request:", data.path);
935
+ if (data.content && data.content.length > MAX_WRITE_SIZE) {
936
+ return {
937
+ success: false,
938
+ error: `File content exceeds maximum allowed size (${MAX_WRITE_SIZE} bytes)`
939
+ };
940
+ }
941
+ const sessionUploadDir = join$1(UPLOAD_TEMP_DIR, safeSessionId);
942
+ const validation = validatePath(data.path, workingDirectory, [
943
+ sessionUploadDir
944
+ ]);
945
+ if (!validation.valid) {
946
+ return { success: false, error: validation.error };
947
+ }
948
+ try {
949
+ const resolvedPath = resolve(workingDirectory, data.path);
950
+ if (data.expectedHash !== null && data.expectedHash !== void 0) {
951
+ try {
952
+ const existingBuffer = await readFile(resolvedPath);
953
+ const existingHash = createHash$1("sha256").update(existingBuffer).digest("hex");
954
+ if (existingHash !== data.expectedHash) {
955
+ return {
956
+ success: false,
957
+ error: `File hash mismatch. Expected: ${data.expectedHash}, Actual: ${existingHash}`
958
+ };
959
+ }
960
+ } catch {
961
+ return {
962
+ success: false,
963
+ error: "File does not exist but expectedHash was provided"
964
+ };
965
+ }
966
+ }
967
+ const dir = resolve(resolvedPath, "..");
968
+ await mkdir(dir, { recursive: true });
969
+ const buffer = Buffer.from(data.content, "base64");
970
+ await writeFile(resolvedPath, buffer);
971
+ const hash = createHash$1("sha256").update(buffer).digest("hex");
972
+ return { success: true, hash };
973
+ } catch (error) {
974
+ logger.debug("Failed to write file:", error);
975
+ return {
976
+ success: false,
977
+ error: error instanceof Error ? error.message : "Failed to write file"
978
+ };
979
+ }
980
+ }
981
+ );
982
+ rpcHandlerManager.registerHandler("listDirectory", async (data) => {
983
+ logger.debug("List directory request:", data.path);
984
+ const validation = validatePath(data.path, workingDirectory);
985
+ if (!validation.valid) {
986
+ return { success: false, error: validation.error };
987
+ }
988
+ try {
989
+ const entries = await readdir(data.path, { withFileTypes: true });
990
+ const directoryEntries = await Promise.all(
991
+ entries.map(async (entry) => {
992
+ const fullPath = join$1(data.path, entry.name);
993
+ let type = "other";
994
+ let size;
995
+ let modified;
996
+ if (entry.isDirectory()) type = "directory";
997
+ else if (entry.isFile()) type = "file";
998
+ try {
999
+ const stats = await stat(fullPath);
1000
+ size = stats.size;
1001
+ modified = stats.mtime.getTime();
1002
+ } catch {
1003
+ }
1004
+ return { name: entry.name, type, size, modified };
1005
+ })
1006
+ );
1007
+ directoryEntries.sort((a, b) => {
1008
+ if (a.type === "directory" && b.type !== "directory") return -1;
1009
+ if (a.type !== "directory" && b.type === "directory") return 1;
1010
+ return a.name.localeCompare(b.name);
1011
+ });
1012
+ return { success: true, entries: directoryEntries };
1013
+ } catch (error) {
1014
+ logger.debug("Failed to list directory:", error);
1015
+ return {
1016
+ success: false,
1017
+ error: error instanceof Error ? error.message : "Failed to list directory"
1018
+ };
1019
+ }
1020
+ });
1021
+ rpcHandlerManager.registerHandler("getUploadDir", async () => {
1022
+ const uploadDir = join$1(UPLOAD_TEMP_DIR, safeSessionId);
1023
+ await mkdir(uploadDir, { recursive: true });
1024
+ return { success: true, path: uploadDir };
1025
+ });
1026
+ }
419
1027
 
420
1028
  class SessionClient extends EventEmitter {
421
1029
  sessionId;
1030
+ rpcHandlerManager;
422
1031
  encryptionKey;
423
1032
  encryptionVariant;
424
1033
  socket;
@@ -427,6 +1036,7 @@ class SessionClient extends EventEmitter {
427
1036
  agentState = null;
428
1037
  agentStateVersion = 0;
429
1038
  aliveInterval = null;
1039
+ thinking = false;
430
1040
  constructor(opts) {
431
1041
  super();
432
1042
  this.sessionId = opts.sessionId;
@@ -437,6 +1047,16 @@ class SessionClient extends EventEmitter {
437
1047
  }
438
1048
  this.on("error", () => {
439
1049
  });
1050
+ this.rpcHandlerManager = createRpcHandlerManager({
1051
+ scopePrefix: `session:${opts.sessionId}`,
1052
+ encryptionKey: opts.encryptionKey,
1053
+ encryptionVariant: opts.encryptionVariant,
1054
+ logger: (msg, data) => logger.debug(msg, data)
1055
+ });
1056
+ if (opts.enableRpc !== false) {
1057
+ const workDir = opts.workingDirectory ?? process.cwd();
1058
+ registerAgentHandlers(this.rpcHandlerManager, workDir, opts.sessionId);
1059
+ }
440
1060
  this.socket = io(opts.serverUrl, {
441
1061
  auth: {
442
1062
  token: opts.token,
@@ -451,84 +1071,17 @@ class SessionClient extends EventEmitter {
451
1071
  transports: ["websocket"],
452
1072
  autoConnect: false
453
1073
  });
454
- this.socket.on("connect", () => {
455
- this.emit("connected");
456
- this.aliveInterval = setInterval(() => {
457
- this.socket.emit("session-alive", {
458
- sid: this.sessionId,
459
- time: Date.now()
460
- });
461
- }, 2e4);
462
- });
463
- this.socket.on("disconnect", (reason) => {
464
- if (this.aliveInterval !== null) {
465
- clearInterval(this.aliveInterval);
466
- this.aliveInterval = null;
467
- }
468
- this.emit("disconnected", reason);
469
- });
470
- this.socket.on("connect_error", (error) => {
471
- this.emit("connect_error", error);
472
- });
473
- this.socket.on("update", (data) => {
474
- try {
475
- const body = data?.body;
476
- if (!body) return;
477
- if (body.t === "new-message" && body.message?.content?.t === "encrypted") {
478
- const msg = body.message;
479
- const decrypted = decrypt(
480
- this.encryptionKey,
481
- this.encryptionVariant,
482
- decodeBase64(msg.content.c)
483
- );
484
- if (decrypted === null) return;
485
- this.emit("message", {
486
- id: msg.id,
487
- seq: msg.seq,
488
- content: decrypted,
489
- localId: msg.localId,
490
- createdAt: msg.createdAt,
491
- updatedAt: msg.updatedAt
492
- });
493
- } else if (body.t === "update-session") {
494
- if (body.metadata && body.metadata.version > this.metadataVersion) {
495
- this.metadata = decrypt(
496
- this.encryptionKey,
497
- this.encryptionVariant,
498
- decodeBase64(body.metadata.value)
499
- );
500
- this.metadataVersion = body.metadata.version;
501
- }
502
- if (body.agentState && body.agentState.version > this.agentStateVersion) {
503
- this.agentState = body.agentState.value ? decrypt(
504
- this.encryptionKey,
505
- this.encryptionVariant,
506
- decodeBase64(body.agentState.value)
507
- ) : null;
508
- this.agentStateVersion = body.agentState.version;
509
- }
510
- this.emit("state-change", {
511
- metadata: this.metadata,
512
- agentState: this.agentState
513
- });
514
- }
515
- } catch (err) {
516
- this.emit("error", err);
517
- }
518
- });
1074
+ this.setupSocketListeners();
519
1075
  this.socket.connect();
520
1076
  }
1077
+ // -----------------------------------------------------------------------
1078
+ // Public API (backward compatible)
1079
+ // -----------------------------------------------------------------------
521
1080
  sendMessage(text, meta) {
522
1081
  const content = {
523
1082
  role: "user",
524
- content: {
525
- type: "text",
526
- text
527
- },
528
- meta: {
529
- sentFrom: "happy-agent",
530
- ...meta
531
- }
1083
+ content: { type: "text", text },
1084
+ meta: { sentFrom: "happy-agent", ...meta }
532
1085
  };
533
1086
  const encrypted = encodeBase64(
534
1087
  encrypt(this.encryptionKey, this.encryptionVariant, content)
@@ -544,6 +1097,9 @@ class SessionClient extends EventEmitter {
544
1097
  getAgentState() {
545
1098
  return this.agentState;
546
1099
  }
1100
+ setThinking(thinking) {
1101
+ this.thinking = thinking;
1102
+ }
547
1103
  waitForConnect(timeoutMs = 1e4) {
548
1104
  return new Promise((resolve, reject) => {
549
1105
  if (this.socket.connected) {
@@ -573,13 +1129,9 @@ class SessionClient extends EventEmitter {
573
1129
  return new Promise((resolve, reject) => {
574
1130
  const checkIdle = () => {
575
1131
  const meta = this.metadata;
576
- if (meta?.lifecycleState === "archived") {
577
- return "archived";
578
- }
1132
+ if (meta?.lifecycleState === "archived") return "archived";
579
1133
  const state = this.agentState;
580
- if (!state) {
581
- return false;
582
- }
1134
+ if (!state) return false;
583
1135
  const controlledByUser = state.controlledByUser === true;
584
1136
  const requests = state.requests;
585
1137
  const hasRequests = requests != null && typeof requests === "object" && !Array.isArray(requests) && Object.keys(requests).length > 0;
@@ -636,8 +1188,352 @@ class SessionClient extends EventEmitter {
636
1188
  clearInterval(this.aliveInterval);
637
1189
  this.aliveInterval = null;
638
1190
  }
1191
+ this.rpcHandlerManager.onSocketDisconnect();
639
1192
  this.socket.close();
640
1193
  }
1194
+ // -----------------------------------------------------------------------
1195
+ // New: OCC metadata/state updates
1196
+ // -----------------------------------------------------------------------
1197
+ async updateMetadata(newMetadata) {
1198
+ const encrypted = encodeBase64(
1199
+ encrypt(this.encryptionKey, this.encryptionVariant, newMetadata)
1200
+ );
1201
+ await withBackoff(
1202
+ () => new Promise((resolve, reject) => {
1203
+ this.socket.emit(
1204
+ "update-metadata",
1205
+ {
1206
+ sid: this.sessionId,
1207
+ expectedVersion: this.metadataVersion,
1208
+ metadata: encrypted
1209
+ },
1210
+ (answer) => {
1211
+ if (answer.result === "success" && answer.version !== void 0) {
1212
+ this.metadataVersion = answer.version;
1213
+ this.metadata = newMetadata;
1214
+ resolve();
1215
+ } else if (answer.result === "version-mismatch" && answer.version !== void 0) {
1216
+ this.metadataVersion = answer.version;
1217
+ if (answer.metadata) {
1218
+ this.metadata = decrypt(
1219
+ this.encryptionKey,
1220
+ this.encryptionVariant,
1221
+ decodeBase64(answer.metadata)
1222
+ );
1223
+ }
1224
+ reject(new Error("version-mismatch"));
1225
+ } else {
1226
+ reject(new Error(`update-metadata failed: ${answer.result}`));
1227
+ }
1228
+ }
1229
+ );
1230
+ }),
1231
+ { maxRetries: 3, label: "updateMetadata" }
1232
+ );
1233
+ }
1234
+ async updateAgentState(newState) {
1235
+ const encrypted = newState !== null ? encodeBase64(encrypt(this.encryptionKey, this.encryptionVariant, newState)) : null;
1236
+ await withBackoff(
1237
+ () => new Promise((resolve, reject) => {
1238
+ this.socket.emit(
1239
+ "update-state",
1240
+ {
1241
+ sid: this.sessionId,
1242
+ expectedVersion: this.agentStateVersion,
1243
+ agentState: encrypted
1244
+ },
1245
+ (answer) => {
1246
+ if (answer.result === "success" && answer.version !== void 0) {
1247
+ this.agentStateVersion = answer.version;
1248
+ this.agentState = newState;
1249
+ resolve();
1250
+ } else if (answer.result === "version-mismatch" && answer.version !== void 0) {
1251
+ this.agentStateVersion = answer.version;
1252
+ if (answer.agentState) {
1253
+ this.agentState = decrypt(
1254
+ this.encryptionKey,
1255
+ this.encryptionVariant,
1256
+ decodeBase64(answer.agentState)
1257
+ );
1258
+ }
1259
+ reject(new Error("version-mismatch"));
1260
+ } else {
1261
+ reject(new Error(`update-state failed: ${answer.result}`));
1262
+ }
1263
+ }
1264
+ );
1265
+ }),
1266
+ { maxRetries: 3, label: "updateAgentState" }
1267
+ );
1268
+ }
1269
+ // -----------------------------------------------------------------------
1270
+ // Private: socket listeners
1271
+ // -----------------------------------------------------------------------
1272
+ setupSocketListeners() {
1273
+ this.socket.on("connect", () => {
1274
+ this.emit("connected");
1275
+ this.rpcHandlerManager.onSocketConnect(this.socket);
1276
+ this.aliveInterval = setInterval(() => {
1277
+ this.socket.emit("session-alive", {
1278
+ sid: this.sessionId,
1279
+ time: Date.now(),
1280
+ thinking: this.thinking
1281
+ });
1282
+ }, 2e4);
1283
+ });
1284
+ this.socket.on("disconnect", (reason) => {
1285
+ if (this.aliveInterval !== null) {
1286
+ clearInterval(this.aliveInterval);
1287
+ this.aliveInterval = null;
1288
+ }
1289
+ this.rpcHandlerManager.onSocketDisconnect();
1290
+ this.emit("disconnected", reason);
1291
+ });
1292
+ this.socket.on("connect_error", (error) => {
1293
+ this.emit("connect_error", error);
1294
+ });
1295
+ this.socket.on("rpc-request", async (data, callback) => {
1296
+ try {
1297
+ const response = await this.rpcHandlerManager.handleRequest(data);
1298
+ callback(response);
1299
+ } catch (err) {
1300
+ logger.error("[RPC] Unhandled error in rpc-request handler", err);
1301
+ }
1302
+ });
1303
+ this.socket.on("update", (data) => {
1304
+ try {
1305
+ const body = data?.body;
1306
+ if (!body) return;
1307
+ if (body.t === "new-message" && body.message?.content?.t === "encrypted") {
1308
+ const msg = body.message;
1309
+ const decrypted = decrypt(
1310
+ this.encryptionKey,
1311
+ this.encryptionVariant,
1312
+ decodeBase64(msg.content.c)
1313
+ );
1314
+ if (decrypted === null) return;
1315
+ this.emit("message", {
1316
+ id: msg.id,
1317
+ seq: msg.seq,
1318
+ content: decrypted,
1319
+ localId: msg.localId,
1320
+ createdAt: msg.createdAt,
1321
+ updatedAt: msg.updatedAt
1322
+ });
1323
+ } else if (body.t === "update-session") {
1324
+ if (body.metadata && body.metadata.version > this.metadataVersion) {
1325
+ this.metadata = decrypt(
1326
+ this.encryptionKey,
1327
+ this.encryptionVariant,
1328
+ decodeBase64(body.metadata.value)
1329
+ );
1330
+ this.metadataVersion = body.metadata.version;
1331
+ }
1332
+ if (body.agentState && body.agentState.version > this.agentStateVersion) {
1333
+ this.agentState = body.agentState.value ? decrypt(
1334
+ this.encryptionKey,
1335
+ this.encryptionVariant,
1336
+ decodeBase64(body.agentState.value)
1337
+ ) : null;
1338
+ this.agentStateVersion = body.agentState.version;
1339
+ }
1340
+ this.emit("state-change", {
1341
+ metadata: this.metadata,
1342
+ agentState: this.agentState
1343
+ });
1344
+ }
1345
+ } catch (err) {
1346
+ this.emit("error", err);
1347
+ }
1348
+ });
1349
+ }
1350
+ }
1351
+
1352
+ class MachineClient {
1353
+ machine;
1354
+ rpcHandlerManager;
1355
+ socket;
1356
+ keepAliveInterval = null;
1357
+ token;
1358
+ serverUrl;
1359
+ onEphemeral;
1360
+ constructor(opts) {
1361
+ this.token = opts.token;
1362
+ this.machine = opts.machine;
1363
+ this.serverUrl = opts.serverUrl;
1364
+ this.onEphemeral = opts.onEphemeral;
1365
+ this.rpcHandlerManager = new RpcHandlerManager({
1366
+ scopePrefix: opts.machine.id,
1367
+ encryptionKey: opts.machine.encryptionKey,
1368
+ encryptionVariant: opts.machine.encryptionVariant,
1369
+ logger: (msg, data) => logger.debug(msg, data)
1370
+ });
1371
+ const workDir = opts.workingDirectory ?? process.cwd();
1372
+ registerAgentHandlers(this.rpcHandlerManager, workDir, opts.machine.id);
1373
+ }
1374
+ // -----------------------------------------------------------------------
1375
+ // Connection
1376
+ // -----------------------------------------------------------------------
1377
+ connect() {
1378
+ logger.debug(`[MACHINE] Connecting to ${this.serverUrl}`);
1379
+ this.socket = io(this.serverUrl, {
1380
+ transports: ["websocket"],
1381
+ auth: {
1382
+ token: this.token,
1383
+ clientType: "machine-scoped",
1384
+ machineId: this.machine.id
1385
+ },
1386
+ path: "/v1/updates",
1387
+ reconnection: true,
1388
+ reconnectionDelay: 1e3,
1389
+ reconnectionDelayMax: 5e3
1390
+ });
1391
+ this.socket.on("connect", () => {
1392
+ logger.debug("[MACHINE] Connected to server");
1393
+ this.rpcHandlerManager.onSocketConnect(this.socket);
1394
+ this.startKeepAlive();
1395
+ });
1396
+ this.socket.on("disconnect", () => {
1397
+ logger.debug("[MACHINE] Disconnected from server");
1398
+ this.rpcHandlerManager.onSocketDisconnect();
1399
+ this.stopKeepAlive();
1400
+ });
1401
+ this.socket.on(
1402
+ "rpc-request",
1403
+ async (data, callback) => {
1404
+ logger.debug("[MACHINE] Received RPC request:", data.method);
1405
+ callback(await this.rpcHandlerManager.handleRequest(data));
1406
+ }
1407
+ );
1408
+ this.socket.on("update", (data) => {
1409
+ const body = data?.body;
1410
+ if (body?.t === "update-machine" && body.machineId === this.machine.id) {
1411
+ if (body.metadata && body.metadata.version > this.machine.metadataVersion) {
1412
+ this.machine.metadata = decrypt(
1413
+ this.machine.encryptionKey,
1414
+ this.machine.encryptionVariant,
1415
+ decodeBase64(body.metadata.value)
1416
+ );
1417
+ this.machine.metadataVersion = body.metadata.version;
1418
+ }
1419
+ if (body.daemonState && body.daemonState.version > this.machine.daemonStateVersion) {
1420
+ this.machine.daemonState = decrypt(
1421
+ this.machine.encryptionKey,
1422
+ this.machine.encryptionVariant,
1423
+ decodeBase64(body.daemonState.value)
1424
+ );
1425
+ this.machine.daemonStateVersion = body.daemonState.version;
1426
+ }
1427
+ }
1428
+ });
1429
+ if (this.onEphemeral) {
1430
+ this.socket.on("ephemeral", (data) => {
1431
+ logger.debug("[MACHINE] Received ephemeral event:", data.type);
1432
+ this.onEphemeral?.(data);
1433
+ });
1434
+ }
1435
+ this.socket.on("connect_error", (error) => {
1436
+ logger.debug(`[MACHINE] Connection error: ${error.message}`);
1437
+ });
1438
+ }
1439
+ // -----------------------------------------------------------------------
1440
+ // OCC updates
1441
+ // -----------------------------------------------------------------------
1442
+ async updateMachineMetadata(handler) {
1443
+ await withBackoff(async () => {
1444
+ const updated = handler(this.machine.metadata);
1445
+ const answer = await new Promise((resolve) => {
1446
+ this.socket.emit(
1447
+ "machine-update-metadata",
1448
+ {
1449
+ machineId: this.machine.id,
1450
+ metadata: encodeBase64(
1451
+ encrypt(this.machine.encryptionKey, this.machine.encryptionVariant, updated)
1452
+ ),
1453
+ expectedVersion: this.machine.metadataVersion
1454
+ },
1455
+ resolve
1456
+ );
1457
+ });
1458
+ if (answer.result === "success") {
1459
+ this.machine.metadata = decrypt(
1460
+ this.machine.encryptionKey,
1461
+ this.machine.encryptionVariant,
1462
+ decodeBase64(answer.metadata)
1463
+ );
1464
+ this.machine.metadataVersion = answer.version;
1465
+ } else if (answer.result === "version-mismatch") {
1466
+ this.machine.metadataVersion = answer.version;
1467
+ this.machine.metadata = decrypt(
1468
+ this.machine.encryptionKey,
1469
+ this.machine.encryptionVariant,
1470
+ decodeBase64(answer.metadata)
1471
+ );
1472
+ throw new Error("Metadata version mismatch");
1473
+ }
1474
+ }, { maxRetries: 3, label: "updateMachineMetadata" });
1475
+ }
1476
+ async updateDaemonState(handler) {
1477
+ await withBackoff(async () => {
1478
+ const updated = handler(this.machine.daemonState);
1479
+ const answer = await new Promise((resolve) => {
1480
+ this.socket.emit(
1481
+ "machine-update-state",
1482
+ {
1483
+ machineId: this.machine.id,
1484
+ daemonState: encodeBase64(
1485
+ encrypt(this.machine.encryptionKey, this.machine.encryptionVariant, updated)
1486
+ ),
1487
+ expectedVersion: this.machine.daemonStateVersion
1488
+ },
1489
+ resolve
1490
+ );
1491
+ });
1492
+ if (answer.result === "success") {
1493
+ this.machine.daemonState = decrypt(
1494
+ this.machine.encryptionKey,
1495
+ this.machine.encryptionVariant,
1496
+ decodeBase64(answer.daemonState)
1497
+ );
1498
+ this.machine.daemonStateVersion = answer.version;
1499
+ } else if (answer.result === "version-mismatch") {
1500
+ this.machine.daemonStateVersion = answer.version;
1501
+ this.machine.daemonState = decrypt(
1502
+ this.machine.encryptionKey,
1503
+ this.machine.encryptionVariant,
1504
+ decodeBase64(answer.daemonState)
1505
+ );
1506
+ throw new Error("Daemon state version mismatch");
1507
+ }
1508
+ }, { maxRetries: 3, label: "updateDaemonState" });
1509
+ }
1510
+ // -----------------------------------------------------------------------
1511
+ // Lifecycle
1512
+ // -----------------------------------------------------------------------
1513
+ shutdown() {
1514
+ logger.debug("[MACHINE] Shutting down");
1515
+ this.stopKeepAlive();
1516
+ this.rpcHandlerManager.onSocketDisconnect();
1517
+ this.socket?.close();
1518
+ }
1519
+ // -----------------------------------------------------------------------
1520
+ // Private
1521
+ // -----------------------------------------------------------------------
1522
+ startKeepAlive() {
1523
+ this.stopKeepAlive();
1524
+ this.keepAliveInterval = setInterval(() => {
1525
+ this.socket.emit("machine-alive", {
1526
+ machineId: this.machine.id,
1527
+ time: Date.now()
1528
+ });
1529
+ }, 2e4);
1530
+ }
1531
+ stopKeepAlive() {
1532
+ if (this.keepAliveInterval) {
1533
+ clearInterval(this.keepAliveInterval);
1534
+ this.keepAliveInterval = null;
1535
+ }
1536
+ }
641
1537
  }
642
1538
 
643
1539
  function formatTime(ts) {
@@ -817,9 +1713,9 @@ function createClient(session, creds, config) {
817
1713
  const program = new Command();
818
1714
  program.name("happy-agent").description("CLI client for controlling Happy Coder agents remotely").version(version);
819
1715
  program.command("auth").description("Manage authentication").addCommand(
820
- new Command("login").description("Authenticate via QR code").action(async () => {
1716
+ new Command("login").description("Authenticate via QR code or web URL").option("--web", "Use web URL instead of QR code (for SSH/headless environments)").action(async (opts) => {
821
1717
  const config = loadConfig();
822
- await authLogin(config);
1718
+ await authLogin(config, { web: opts.web });
823
1719
  })
824
1720
  ).addCommand(
825
1721
  new Command("logout").description("Clear stored credentials").action(async () => {
@@ -1029,7 +1925,55 @@ program.command("wait").description("Wait for agent to become idle").argument("<
1029
1925
  client.close();
1030
1926
  }
1031
1927
  });
1928
+ program.command("machine").description("Manage machine identity").addCommand(
1929
+ new Command("register").description("Register this machine with the server").option("--json", "Output as JSON").action(async (opts) => {
1930
+ const config = loadConfig();
1931
+ const creds = requireCredentials(config);
1932
+ const metadata = {
1933
+ host: hostname(),
1934
+ platform: process.platform,
1935
+ happyCliVersion: version,
1936
+ homeDir: config.homeDir,
1937
+ happyHomeDir: config.homeDir,
1938
+ happyLibDir: config.homeDir
1939
+ };
1940
+ const machine = await getOrCreateMachine(config, creds, metadata);
1941
+ if (opts.json) {
1942
+ console.log(formatJson({ id: machine.id, metadata: machine.metadata }));
1943
+ } else {
1944
+ console.log(
1945
+ [
1946
+ "## Machine Registered",
1947
+ "",
1948
+ `- Machine ID: \`${machine.id}\``,
1949
+ `- Host: ${machine.metadata.host}`,
1950
+ `- Platform: ${machine.metadata.platform}`
1951
+ ].join("\n")
1952
+ );
1953
+ }
1954
+ })
1955
+ ).addCommand(
1956
+ new Command("list").description("List all registered machines").option("--json", "Output as JSON").action(async (opts) => {
1957
+ const config = loadConfig();
1958
+ const creds = requireCredentials(config);
1959
+ const machines = await listMachines(config, creds);
1960
+ if (opts.json) {
1961
+ console.log(formatJson(machines));
1962
+ } else {
1963
+ if (machines.length === 0) {
1964
+ console.log("No machines registered.");
1965
+ } else {
1966
+ console.log(`## Machines (${machines.length})`);
1967
+ for (const m of machines) {
1968
+ console.log(`- \`${m.id}\``);
1969
+ }
1970
+ }
1971
+ }
1972
+ })
1973
+ );
1032
1974
  program.parseAsync(process.argv).catch((err) => {
1033
1975
  console.error(err instanceof Error ? err.message : String(err));
1034
1976
  process.exitCode = 1;
1035
1977
  });
1978
+
1979
+ export { MachineClient, RpcHandlerManager, SessionClient, authLogin, authLogout, authStatus, createRpcHandlerManager, createSession, deleteSession, fetchMessagesAfterSeq, getOrCreateMachine, getSessionMessages, listActiveSessions, listMachines, listSessions, loadConfig, readCredentials, requireCredentials, resolveSessionEncryption, sendMessagesBatch };