happy-coder 0.6.4 → 0.7.1-beta.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var chalk = require('chalk');
4
- var types$1 = require('./types-BDtHM1DY.cjs');
4
+ var types$1 = require('./types-CzvFvJwf.cjs');
5
5
  var node_crypto = require('node:crypto');
6
6
  var node_child_process = require('node:child_process');
7
7
  var node_path = require('node:path');
@@ -16,8 +16,8 @@ var promises = require('fs/promises');
16
16
  var ink = require('ink');
17
17
  var React = require('react');
18
18
  var axios = require('axios');
19
- var node_events = require('node:events');
20
- var socket_ioClient = require('socket.io-client');
19
+ require('node:events');
20
+ require('socket.io-client');
21
21
  var tweetnacl = require('tweetnacl');
22
22
  require('expo-server-sdk');
23
23
  var mcp_js = require('@modelcontextprotocol/sdk/server/mcp.js');
@@ -29,8 +29,10 @@ var util = require('util');
29
29
  var crypto = require('crypto');
30
30
  var qrcode = require('qrcode-terminal');
31
31
  var open = require('open');
32
- var fs = require('fs');
32
+ var fastify = require('fastify');
33
+ var fastifyTypeProviderZod = require('fastify-type-provider-zod');
33
34
  var os$1 = require('os');
35
+ var fs = require('fs');
34
36
 
35
37
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
36
38
  function _interopNamespaceDefault(e) {
@@ -104,7 +106,8 @@ class Session {
104
106
 
105
107
  function getProjectPath(workingDirectory) {
106
108
  const projectId = node_path.resolve(workingDirectory).replace(/[\\\/\.:]/g, "-");
107
- return node_path.join(os.homedir(), ".claude", "projects", projectId);
109
+ const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || node_path.join(os.homedir(), ".claude");
110
+ return node_path.join(claudeConfigDir, "projects", projectId);
108
111
  }
109
112
 
110
113
  function claudeCheckSession(sessionId, path) {
@@ -2051,31 +2054,39 @@ class SDKToLogConverter {
2051
2054
  }
2052
2055
 
2053
2056
  async function claudeRemoteLauncher(session) {
2057
+ types$1.logger.debug("[claudeRemoteLauncher] Starting remote launcher");
2058
+ const hasTTY = process.stdout.isTTY && process.stdin.isTTY;
2059
+ types$1.logger.debug(`[claudeRemoteLauncher] TTY available: ${hasTTY}`);
2054
2060
  let messageBuffer = new MessageBuffer();
2055
- console.clear();
2056
- let inkInstance = ink.render(React.createElement(RemoteModeDisplay, {
2057
- messageBuffer,
2058
- logPath: process.env.DEBUG ? session.logPath : void 0,
2059
- onExit: async () => {
2060
- types$1.logger.debug("[remote]: Exiting client via Ctrl-C");
2061
- if (!exitReason) {
2062
- exitReason = "exit";
2061
+ let inkInstance = null;
2062
+ if (hasTTY) {
2063
+ console.clear();
2064
+ inkInstance = ink.render(React.createElement(RemoteModeDisplay, {
2065
+ messageBuffer,
2066
+ logPath: process.env.DEBUG ? session.logPath : void 0,
2067
+ onExit: async () => {
2068
+ types$1.logger.debug("[remote]: Exiting client via Ctrl-C");
2069
+ if (!exitReason) {
2070
+ exitReason = "exit";
2071
+ }
2072
+ await abort();
2073
+ },
2074
+ onSwitchToLocal: () => {
2075
+ types$1.logger.debug("[remote]: Switching to local mode via double space");
2076
+ doSwitch();
2063
2077
  }
2064
- await abort();
2065
- },
2066
- onSwitchToLocal: () => {
2067
- types$1.logger.debug("[remote]: Switching to local mode via double space");
2068
- doSwitch();
2078
+ }), {
2079
+ exitOnCtrlC: false,
2080
+ patchConsole: false
2081
+ });
2082
+ }
2083
+ if (hasTTY) {
2084
+ process.stdin.resume();
2085
+ if (process.stdin.isTTY) {
2086
+ process.stdin.setRawMode(true);
2069
2087
  }
2070
- }), {
2071
- exitOnCtrlC: false,
2072
- patchConsole: false
2073
- });
2074
- process.stdin.resume();
2075
- if (process.stdin.isTTY) {
2076
- process.stdin.setRawMode(true);
2088
+ process.stdin.setEncoding("utf8");
2077
2089
  }
2078
- process.stdin.setEncoding("utf8");
2079
2090
  const scanner = await createSessionScanner({
2080
2091
  sessionId: session.sessionId,
2081
2092
  workingDirectory: session.path,
@@ -2295,7 +2306,9 @@ async function claudeRemoteLauncher(session) {
2295
2306
  if (process.stdin.isTTY) {
2296
2307
  process.stdin.setRawMode(false);
2297
2308
  }
2298
- inkInstance.unmount();
2309
+ if (inkInstance) {
2310
+ inkInstance.unmount();
2311
+ }
2299
2312
  messageBuffer.clear();
2300
2313
  if (abortFuture) {
2301
2314
  abortFuture.resolve(void 0);
@@ -2351,7 +2364,7 @@ async function loop(opts) {
2351
2364
  }
2352
2365
 
2353
2366
  var name = "happy-coder";
2354
- var version = "0.6.4";
2367
+ var version = "0.7.1-beta.1";
2355
2368
  var description = "Claude Code session sharing CLI";
2356
2369
  var author = "Kirill Dubovitskiy";
2357
2370
  var license = "MIT";
@@ -2395,15 +2408,21 @@ var files = [
2395
2408
  "package.json"
2396
2409
  ];
2397
2410
  var scripts = {
2398
- test: "vitest run",
2411
+ "why do we need to build before running tests / dev?": "We need the binary to be built so we run daemon commands which directly run the binary - we don't want them to go out of sync or have custom spawn logic depending how we started happy",
2412
+ typecheck: "tsc --noEmit",
2413
+ build: "shx rm -rf dist && npx tsc --noEmit && pkgroll",
2414
+ test: "yarn build && vitest run",
2399
2415
  "test:watch": "vitest",
2400
- build: "shx rm -rf dist && tsc --noEmit && pkgroll",
2416
+ "test:integration-test-env": "yarn build && tsx --env-file .env.integration-test node_modules/.bin/vitest run",
2417
+ dev: "yarn build && npx tsx src/index.ts",
2418
+ "dev:local-server": "yarn build && tsx --env-file .env.dev-local-server src/index.ts",
2419
+ "dev:integration-test-env": "yarn build && tsx --env-file .env.integration-test src/index.ts",
2401
2420
  prepublishOnly: "yarn build && yarn test",
2402
- typecheck: "tsc --noEmit",
2403
- dev: "yarn build && npx tsx --env-file .env.sample src/index.ts",
2404
- "dev:local-server": "yarn build && cross-env HANDY_SERVER_URL=http://localhost:3005 tsx --env-file .env.sample src/index.ts",
2405
- "version:prerelease": "npm version prerelease --preid=beta",
2406
- "publish:prerelease": "npm publish --tag beta"
2421
+ "minor:publish": "yarn build && npm version minor && npm publish",
2422
+ "patch:publish": "yarn build && npm version patch && npm publish",
2423
+ "version:prerelease": "yarn build && npm version prerelease --preid=beta",
2424
+ "publish:prerelease": "npm publish --tag beta",
2425
+ "beta:publish": "yarn version:prerelease && yarn publish:prerelease"
2407
2426
  };
2408
2427
  var dependencies = {
2409
2428
  "@anthropic-ai/claude-code": "^1.0.73",
@@ -2416,6 +2435,8 @@ var dependencies = {
2416
2435
  axios: "^1.10.0",
2417
2436
  chalk: "^5.4.1",
2418
2437
  "expo-server-sdk": "^3.15.0",
2438
+ fastify: "^5.5.0",
2439
+ "fastify-type-provider-zod": "4.0.2",
2419
2440
  "http-proxy": "^1.18.1",
2420
2441
  "http-proxy-middleware": "^3.0.5",
2421
2442
  ink: "^6.1.0",
@@ -2505,8 +2526,8 @@ function registerHandlers(session) {
2505
2526
  const { stdout, stderr } = await execAsync(data.command, options);
2506
2527
  return {
2507
2528
  success: true,
2508
- stdout: stdout || "",
2509
- stderr: stderr || "",
2529
+ stdout: stdout ? stdout.toString() : "",
2530
+ stderr: stderr ? stderr.toString() : "",
2510
2531
  exitCode: 0
2511
2532
  };
2512
2533
  } catch (error) {
@@ -2522,8 +2543,8 @@ function registerHandlers(session) {
2522
2543
  }
2523
2544
  return {
2524
2545
  success: false,
2525
- stdout: execError.stdout || "",
2526
- stderr: execError.stderr || execError.message || "Command failed",
2546
+ stdout: execError.stdout ? execError.stdout.toString() : "",
2547
+ stderr: execError.stderr ? execError.stderr.toString() : execError.message || "Command failed",
2527
2548
  exitCode: typeof execError.code === "number" ? execError.code : 1,
2528
2549
  error: execError.message || "Command failed"
2529
2550
  };
@@ -2690,8 +2711,8 @@ function registerHandlers(session) {
2690
2711
  return {
2691
2712
  success: true,
2692
2713
  exitCode: result.exitCode,
2693
- stdout: result.stdout,
2694
- stderr: result.stderr
2714
+ stdout: result.stdout.toString(),
2715
+ stderr: result.stderr.toString()
2695
2716
  };
2696
2717
  } catch (error) {
2697
2718
  types$1.logger.debug("Failed to run ripgrep:", error);
@@ -2701,6 +2722,26 @@ function registerHandlers(session) {
2701
2722
  };
2702
2723
  }
2703
2724
  });
2725
+ session.setHandler("killSession", async () => {
2726
+ types$1.logger.debug("Kill session request received");
2727
+ try {
2728
+ const response = {
2729
+ success: true,
2730
+ message: "Session termination acknowledged, exiting in 100ms"
2731
+ };
2732
+ setTimeout(() => {
2733
+ types$1.logger.debug("[KILL SESSION] Exiting process as requested");
2734
+ process.exit(0);
2735
+ }, 100);
2736
+ return response;
2737
+ } catch (error) {
2738
+ types$1.logger.debug("Failed to kill session:", error);
2739
+ return {
2740
+ success: false,
2741
+ message: error instanceof Error ? error.message : "Failed to kill session"
2742
+ };
2743
+ }
2744
+ });
2704
2745
  }
2705
2746
 
2706
2747
  const defaultSettings = {
@@ -2717,11 +2758,52 @@ async function readSettings() {
2717
2758
  return { ...defaultSettings };
2718
2759
  }
2719
2760
  }
2720
- async function writeSettings(settings) {
2721
- if (!node_fs.existsSync(types$1.configuration.happyDir)) {
2722
- await promises$1.mkdir(types$1.configuration.happyDir, { recursive: true });
2761
+ async function updateSettings(updater) {
2762
+ const LOCK_RETRY_INTERVAL_MS = 100;
2763
+ const MAX_LOCK_ATTEMPTS = 50;
2764
+ const STALE_LOCK_TIMEOUT_MS = 1e4;
2765
+ const lockFile = types$1.configuration.settingsFile + ".lock";
2766
+ const tmpFile = types$1.configuration.settingsFile + ".tmp";
2767
+ let fileHandle;
2768
+ let attempts = 0;
2769
+ while (attempts < MAX_LOCK_ATTEMPTS) {
2770
+ try {
2771
+ fileHandle = await promises$1.open(lockFile, node_fs.constants.O_CREAT | node_fs.constants.O_EXCL | node_fs.constants.O_WRONLY);
2772
+ break;
2773
+ } catch (err) {
2774
+ if (err.code === "EEXIST") {
2775
+ attempts++;
2776
+ await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS));
2777
+ try {
2778
+ const stats = await promises$1.stat(lockFile);
2779
+ if (Date.now() - stats.mtimeMs > STALE_LOCK_TIMEOUT_MS) {
2780
+ await promises$1.unlink(lockFile).catch(() => {
2781
+ });
2782
+ }
2783
+ } catch {
2784
+ }
2785
+ } else {
2786
+ throw err;
2787
+ }
2788
+ }
2789
+ }
2790
+ if (!fileHandle) {
2791
+ throw new Error(`Failed to acquire settings lock after ${MAX_LOCK_ATTEMPTS * LOCK_RETRY_INTERVAL_MS / 1e3} seconds`);
2792
+ }
2793
+ try {
2794
+ const current = await readSettings() || { ...defaultSettings };
2795
+ const updated = await updater(current);
2796
+ if (!node_fs.existsSync(types$1.configuration.happyHomeDir)) {
2797
+ await promises$1.mkdir(types$1.configuration.happyHomeDir, { recursive: true });
2798
+ }
2799
+ await promises$1.writeFile(tmpFile, JSON.stringify(updated, null, 2));
2800
+ await promises$1.rename(tmpFile, types$1.configuration.settingsFile);
2801
+ return updated;
2802
+ } finally {
2803
+ await fileHandle.close();
2804
+ await promises$1.unlink(lockFile).catch(() => {
2805
+ });
2723
2806
  }
2724
- await promises$1.writeFile(types$1.configuration.settingsFile, JSON.stringify(settings, null, 2));
2725
2807
  }
2726
2808
  const credentialsSchema = z__namespace.object({
2727
2809
  secret: z__namespace.string().base64(),
@@ -2743,14 +2825,47 @@ async function readCredentials() {
2743
2825
  }
2744
2826
  }
2745
2827
  async function writeCredentials(credentials) {
2746
- if (!node_fs.existsSync(types$1.configuration.happyDir)) {
2747
- await promises$1.mkdir(types$1.configuration.happyDir, { recursive: true });
2828
+ if (!node_fs.existsSync(types$1.configuration.happyHomeDir)) {
2829
+ await promises$1.mkdir(types$1.configuration.happyHomeDir, { recursive: true });
2748
2830
  }
2749
2831
  await promises$1.writeFile(types$1.configuration.privateKeyFile, JSON.stringify({
2750
2832
  secret: types$1.encodeBase64(credentials.secret),
2751
2833
  token: credentials.token
2752
2834
  }, null, 2));
2753
2835
  }
2836
+ async function clearCredentials() {
2837
+ if (node_fs.existsSync(types$1.configuration.privateKeyFile)) {
2838
+ await promises$1.unlink(types$1.configuration.privateKeyFile);
2839
+ }
2840
+ }
2841
+ async function clearMachineId() {
2842
+ await updateSettings((settings) => ({
2843
+ ...settings,
2844
+ machineId: void 0
2845
+ }));
2846
+ }
2847
+ async function readDaemonState() {
2848
+ try {
2849
+ if (!node_fs.existsSync(types$1.configuration.daemonStateFile)) {
2850
+ return null;
2851
+ }
2852
+ const content = await promises$1.readFile(types$1.configuration.daemonStateFile, "utf-8");
2853
+ return JSON.parse(content);
2854
+ } catch (error) {
2855
+ return null;
2856
+ }
2857
+ }
2858
+ async function writeDaemonState(state) {
2859
+ if (!node_fs.existsSync(types$1.configuration.happyHomeDir)) {
2860
+ await promises$1.mkdir(types$1.configuration.happyHomeDir, { recursive: true });
2861
+ }
2862
+ await promises$1.writeFile(types$1.configuration.daemonStateFile, JSON.stringify(state, null, 2));
2863
+ }
2864
+ async function clearDaemonState() {
2865
+ if (node_fs.existsSync(types$1.configuration.daemonStateFile)) {
2866
+ await promises$1.unlink(types$1.configuration.daemonStateFile);
2867
+ }
2868
+ }
2754
2869
 
2755
2870
  class MessageQueue2 {
2756
2871
  constructor(modeHasher) {
@@ -3113,8 +3228,13 @@ function startCaffeinate() {
3113
3228
  return false;
3114
3229
  }
3115
3230
  }
3231
+ let isStopping = false;
3116
3232
  function stopCaffeinate() {
3233
+ if (isStopping) {
3234
+ return;
3235
+ }
3117
3236
  if (caffeinateProcess && !caffeinateProcess.killed) {
3237
+ isStopping = true;
3118
3238
  types$1.logger.debug(`[caffeinate] Stopping caffeinate process PID ${caffeinateProcess.pid}`);
3119
3239
  try {
3120
3240
  caffeinateProcess.kill("SIGTERM");
@@ -3124,9 +3244,11 @@ function stopCaffeinate() {
3124
3244
  caffeinateProcess.kill("SIGKILL");
3125
3245
  }
3126
3246
  caffeinateProcess = null;
3247
+ isStopping = false;
3127
3248
  }, 1e3);
3128
3249
  } catch (error) {
3129
3250
  types$1.logger.debug("[caffeinate] Error stopping caffeinate:", error);
3251
+ isStopping = false;
3130
3252
  }
3131
3253
  }
3132
3254
  }
@@ -3201,30 +3323,441 @@ function extractSDKMetadataAsync(onComplete) {
3201
3323
  });
3202
3324
  }
3203
3325
 
3326
+ async function isDaemonRunning() {
3327
+ try {
3328
+ const state = await getDaemonState();
3329
+ if (!state) {
3330
+ return false;
3331
+ }
3332
+ const isRunning = await isDaemonProcessRunning(state.pid);
3333
+ if (!isRunning) {
3334
+ types$1.logger.debug("[DAEMON RUN] Daemon PID not running, cleaning up state");
3335
+ await cleanupDaemonState();
3336
+ return false;
3337
+ }
3338
+ return true;
3339
+ } catch (error) {
3340
+ types$1.logger.debug("[DAEMON RUN] Error checking daemon status", error);
3341
+ return false;
3342
+ }
3343
+ }
3344
+ async function getDaemonState() {
3345
+ try {
3346
+ return await readDaemonState();
3347
+ } catch (error) {
3348
+ types$1.logger.debug("[DAEMON RUN] Error reading daemon metadata", error);
3349
+ return null;
3350
+ }
3351
+ }
3352
+ async function isDaemonProcessRunning(pid) {
3353
+ try {
3354
+ process.kill(pid, 0);
3355
+ return true;
3356
+ } catch {
3357
+ return false;
3358
+ }
3359
+ }
3360
+ async function cleanupDaemonState() {
3361
+ try {
3362
+ await clearDaemonState();
3363
+ types$1.logger.debug("[DAEMON RUN] Daemon state file removed");
3364
+ } catch (error) {
3365
+ types$1.logger.debug("[DAEMON RUN] Error cleaning up daemon metadata", error);
3366
+ }
3367
+ }
3368
+ function findAllHappyProcesses() {
3369
+ try {
3370
+ const output = node_child_process.execSync('ps aux | grep "happy.mjs" | grep -v grep', { encoding: "utf8" });
3371
+ const lines = output.trim().split("\n").filter((line) => line.trim());
3372
+ const allProcesses = [];
3373
+ for (const line of lines) {
3374
+ const parts = line.trim().split(/\s+/);
3375
+ if (parts.length < 11) continue;
3376
+ const pid = parseInt(parts[1]);
3377
+ const command = parts.slice(10).join(" ");
3378
+ let type = "unknown";
3379
+ if (pid === process.pid) {
3380
+ type = "current";
3381
+ } else if (command.includes("daemon start-sync") || command.includes("daemon start")) {
3382
+ type = "daemon";
3383
+ } else if (command.includes("--started-by daemon")) {
3384
+ type = "daemon-spawned-session";
3385
+ } else if (command.includes("doctor")) {
3386
+ type = "doctor";
3387
+ } else {
3388
+ type = "user-session";
3389
+ }
3390
+ allProcesses.push({ pid, command, type });
3391
+ }
3392
+ try {
3393
+ const devOutput = node_child_process.execSync('ps aux | grep -E "(tsx.*src/index.ts|yarn.*tsx)" | grep -v grep', { encoding: "utf8" });
3394
+ const devLines = devOutput.trim().split("\n").filter((line) => line.trim());
3395
+ for (const line of devLines) {
3396
+ const parts = line.trim().split(/\s+/);
3397
+ if (parts.length < 11) continue;
3398
+ const pid = parseInt(parts[1]);
3399
+ const command = parts.slice(10).join(" ");
3400
+ let workingDir = "";
3401
+ try {
3402
+ const pwdOutput = node_child_process.execSync(`pwdx ${pid} 2>/dev/null`, { encoding: "utf8" });
3403
+ workingDir = pwdOutput.replace(`${pid}:`, "").trim();
3404
+ } catch {
3405
+ }
3406
+ if (workingDir.includes("happy-cli")) {
3407
+ allProcesses.push({ pid, command, type: "dev-session" });
3408
+ }
3409
+ }
3410
+ } catch {
3411
+ }
3412
+ return allProcesses;
3413
+ } catch (error) {
3414
+ return [];
3415
+ }
3416
+ }
3417
+ function findRunawayHappyProcesses() {
3418
+ try {
3419
+ const output = node_child_process.execSync('ps aux | grep "happy.mjs" | grep -v grep', { encoding: "utf8" });
3420
+ const lines = output.trim().split("\n").filter((line) => line.trim());
3421
+ const processes = [];
3422
+ for (const line of lines) {
3423
+ const parts = line.trim().split(/\s+/);
3424
+ if (parts.length < 11) continue;
3425
+ const pid = parseInt(parts[1]);
3426
+ const command = parts.slice(10).join(" ");
3427
+ if (pid === process.pid) continue;
3428
+ if (command.includes("--started-by daemon") || command.includes("daemon start-sync") || command.includes("daemon start")) {
3429
+ processes.push({ pid, command });
3430
+ }
3431
+ }
3432
+ return processes;
3433
+ } catch (error) {
3434
+ return [];
3435
+ }
3436
+ }
3437
+ async function killRunawayHappyProcesses() {
3438
+ const runawayProcesses = findRunawayHappyProcesses();
3439
+ const errors = [];
3440
+ let killed = 0;
3441
+ for (const { pid, command } of runawayProcesses) {
3442
+ try {
3443
+ process.kill(pid, "SIGTERM");
3444
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
3445
+ try {
3446
+ process.kill(pid, 0);
3447
+ console.log(`Process PID ${pid} ignored SIGTERM, using SIGKILL`);
3448
+ process.kill(pid, "SIGKILL");
3449
+ } catch {
3450
+ }
3451
+ killed++;
3452
+ console.log(`Killed runaway process PID ${pid}: ${command}`);
3453
+ } catch (error) {
3454
+ errors.push({ pid, error: error.message });
3455
+ }
3456
+ }
3457
+ return { killed, errors };
3458
+ }
3459
+ async function stopDaemon() {
3460
+ try {
3461
+ stopCaffeinate();
3462
+ types$1.logger.debug("Stopped sleep prevention");
3463
+ const state = await getDaemonState();
3464
+ if (!state) {
3465
+ types$1.logger.debug("No daemon state found");
3466
+ return;
3467
+ }
3468
+ types$1.logger.debug(`Stopping daemon with PID ${state.pid}`);
3469
+ try {
3470
+ const { stopDaemonHttp } = await Promise.resolve().then(function () { return controlClient; });
3471
+ await stopDaemonHttp();
3472
+ await waitForProcessDeath(state.pid, 5e3);
3473
+ types$1.logger.debug("Daemon stopped gracefully via HTTP");
3474
+ return;
3475
+ } catch (error) {
3476
+ types$1.logger.debug("HTTP stop failed, will force kill", error);
3477
+ }
3478
+ try {
3479
+ process.kill(state.pid, "SIGKILL");
3480
+ types$1.logger.debug("Force killed daemon");
3481
+ } catch (error) {
3482
+ types$1.logger.debug("Daemon already dead");
3483
+ }
3484
+ } catch (error) {
3485
+ types$1.logger.debug("Error stopping daemon", error);
3486
+ }
3487
+ }
3488
+ async function waitForProcessDeath(pid, timeout) {
3489
+ const start = Date.now();
3490
+ while (Date.now() - start < timeout) {
3491
+ try {
3492
+ process.kill(pid, 0);
3493
+ await new Promise((resolve) => setTimeout(resolve, 100));
3494
+ } catch {
3495
+ return;
3496
+ }
3497
+ }
3498
+ throw new Error("Process did not die within timeout");
3499
+ }
3500
+
3501
+ var utils = /*#__PURE__*/Object.freeze({
3502
+ __proto__: null,
3503
+ cleanupDaemonState: cleanupDaemonState,
3504
+ findAllHappyProcesses: findAllHappyProcesses,
3505
+ findRunawayHappyProcesses: findRunawayHappyProcesses,
3506
+ getDaemonState: getDaemonState,
3507
+ isDaemonRunning: isDaemonRunning,
3508
+ killRunawayHappyProcesses: killRunawayHappyProcesses,
3509
+ stopDaemon: stopDaemon
3510
+ });
3511
+
3512
+ function getEnvironmentInfo() {
3513
+ return {
3514
+ PWD: process.env.PWD,
3515
+ HAPPY_HOME_DIR: process.env.HAPPY_HOME_DIR,
3516
+ HAPPY_SERVER_URL: process.env.HAPPY_SERVER_URL,
3517
+ HAPPY_PROJECT_ROOT: process.env.HAPPY_PROJECT_ROOT,
3518
+ DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING: process.env.DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING,
3519
+ NODE_ENV: process.env.NODE_ENV,
3520
+ DEBUG: process.env.DEBUG,
3521
+ workingDirectory: process.cwd(),
3522
+ processArgv: process.argv,
3523
+ happyDir: types$1.configuration?.happyHomeDir,
3524
+ serverUrl: types$1.configuration?.serverUrl,
3525
+ logsDir: types$1.configuration?.logsDir
3526
+ };
3527
+ }
3528
+ function getLogFiles(logDir) {
3529
+ if (!node_fs.existsSync(logDir)) {
3530
+ return [];
3531
+ }
3532
+ try {
3533
+ return node_fs.readdirSync(logDir).filter((file) => file.endsWith(".log")).map((file) => {
3534
+ const path = node_path.join(logDir, file);
3535
+ const stats = node_fs.statSync(path);
3536
+ return { file, path, modified: stats.mtime };
3537
+ }).sort((a, b) => b.modified.getTime() - a.modified.getTime()).slice(0, 10);
3538
+ } catch {
3539
+ return [];
3540
+ }
3541
+ }
3542
+ async function runDoctorCommand() {
3543
+ console.log(chalk.bold.cyan("\n\u{1FA7A} Happy CLI Doctor\n"));
3544
+ console.log(chalk.bold("\u{1F4CB} Basic Information"));
3545
+ console.log(`Happy CLI Version: ${chalk.green(packageJson.version)}`);
3546
+ console.log(`Platform: ${chalk.green(process.platform)} ${process.arch}`);
3547
+ console.log(`Node.js Version: ${chalk.green(process.version)}`);
3548
+ console.log("");
3549
+ console.log(chalk.bold("\u2699\uFE0F Configuration"));
3550
+ console.log(`Happy Home: ${chalk.blue(types$1.configuration.happyHomeDir)}`);
3551
+ console.log(`Server URL: ${chalk.blue(types$1.configuration.serverUrl)}`);
3552
+ console.log(`Logs Dir: ${chalk.blue(types$1.configuration.logsDir)}`);
3553
+ console.log(chalk.bold("\n\u{1F30D} Environment Variables"));
3554
+ const env = getEnvironmentInfo();
3555
+ console.log(`HAPPY_HOME_DIR: ${env.HAPPY_HOME_DIR ? chalk.green(env.HAPPY_HOME_DIR) : chalk.gray("not set")}`);
3556
+ console.log(`HAPPY_SERVER_URL: ${env.HAPPY_SERVER_URL ? chalk.green(env.HAPPY_SERVER_URL) : chalk.gray("not set")}`);
3557
+ console.log(`DANGEROUSLY_LOG_TO_SERVER: ${env.DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING ? chalk.yellow("ENABLED") : chalk.gray("not set")}`);
3558
+ console.log(`DEBUG: ${env.DEBUG ? chalk.green(env.DEBUG) : chalk.gray("not set")}`);
3559
+ console.log(`NODE_ENV: ${env.NODE_ENV ? chalk.green(env.NODE_ENV) : chalk.gray("not set")}`);
3560
+ try {
3561
+ const settings = await readSettings();
3562
+ console.log(chalk.bold("\n\u{1F4C4} Settings (settings.json):"));
3563
+ console.log(chalk.gray(JSON.stringify(settings, null, 2)));
3564
+ } catch (error) {
3565
+ console.log(chalk.bold("\n\u{1F4C4} Settings:"));
3566
+ console.log(chalk.red("\u274C Failed to read settings"));
3567
+ }
3568
+ console.log(chalk.bold("\n\u{1F510} Authentication"));
3569
+ try {
3570
+ const credentials = await readCredentials();
3571
+ if (credentials) {
3572
+ console.log(chalk.green("\u2713 Authenticated (credentials found)"));
3573
+ } else {
3574
+ console.log(chalk.yellow("\u26A0\uFE0F Not authenticated (no credentials)"));
3575
+ }
3576
+ } catch (error) {
3577
+ console.log(chalk.red("\u274C Error reading credentials"));
3578
+ }
3579
+ console.log(chalk.bold("\n\u{1F916} Daemon Status"));
3580
+ try {
3581
+ const isRunning = await isDaemonRunning();
3582
+ const state = await getDaemonState();
3583
+ if (isRunning && state) {
3584
+ console.log(chalk.green("\u2713 Daemon is running"));
3585
+ console.log(` PID: ${state.pid}`);
3586
+ console.log(` Started: ${new Date(state.startTime).toLocaleString()}`);
3587
+ console.log(` CLI Version: ${state.startedWithCliVersion}`);
3588
+ if (state.httpPort) {
3589
+ console.log(` HTTP Port: ${state.httpPort}`);
3590
+ }
3591
+ } else if (state && !isRunning) {
3592
+ console.log(chalk.yellow("\u26A0\uFE0F Daemon state exists but process not running (stale)"));
3593
+ } else {
3594
+ console.log(chalk.red("\u274C Daemon is not running"));
3595
+ }
3596
+ if (state) {
3597
+ console.log(chalk.bold("\n\u{1F4C4} Daemon State:"));
3598
+ console.log(chalk.blue(`Location: ${types$1.configuration.daemonStateFile}`));
3599
+ console.log(chalk.gray(JSON.stringify(state, null, 2)));
3600
+ }
3601
+ const allProcesses = findAllHappyProcesses();
3602
+ if (allProcesses.length > 0) {
3603
+ console.log(chalk.bold("\n\u{1F50D} All Happy CLI Processes"));
3604
+ const grouped = allProcesses.reduce((groups, process2) => {
3605
+ if (!groups[process2.type]) groups[process2.type] = [];
3606
+ groups[process2.type].push(process2);
3607
+ return groups;
3608
+ }, {});
3609
+ Object.entries(grouped).forEach(([type, processes]) => {
3610
+ const typeLabels = {
3611
+ "current": "\u{1F4CD} Current Process",
3612
+ "daemon": "\u{1F916} Daemon",
3613
+ "daemon-spawned-session": "\u{1F517} Daemon-Spawned Sessions",
3614
+ "user-session": "\u{1F464} User Sessions",
3615
+ "dev-daemon": "\u{1F6E0}\uFE0F Dev Daemon",
3616
+ "dev-session": "\u{1F6E0}\uFE0F Dev Sessions",
3617
+ "dev-doctor": "\u{1F6E0}\uFE0F Dev Doctor",
3618
+ "dev-related": "\u{1F6E0}\uFE0F Dev Related",
3619
+ "doctor": "\u{1FA7A} Doctor",
3620
+ "unknown": "\u2753 Unknown"
3621
+ };
3622
+ console.log(chalk.blue(`
3623
+ ${typeLabels[type] || type}:`));
3624
+ processes.forEach(({ pid, command }) => {
3625
+ const color = type === "current" ? chalk.green : type.startsWith("dev") ? chalk.cyan : type.includes("daemon") ? chalk.blue : chalk.gray;
3626
+ console.log(` ${color(`PID ${pid}`)}: ${chalk.gray(command)}`);
3627
+ });
3628
+ });
3629
+ }
3630
+ const runawayProcesses = findRunawayHappyProcesses();
3631
+ if (runawayProcesses.length > 0) {
3632
+ console.log(chalk.bold("\n\u{1F6A8} Runaway Happy processes detected"));
3633
+ console.log(chalk.gray("These processes were left running after daemon crashes."));
3634
+ runawayProcesses.forEach(({ pid, command }) => {
3635
+ console.log(` ${chalk.yellow(`PID ${pid}`)}: ${chalk.gray(command)}`);
3636
+ });
3637
+ console.log(chalk.blue("\nTo clean up: happy daemon kill-runaway"));
3638
+ }
3639
+ if (allProcesses.length > 1) {
3640
+ console.log(chalk.bold("\n\u{1F4A1} Process Management"));
3641
+ console.log(chalk.gray("To kill runaway processes: happy daemon kill-runaway"));
3642
+ }
3643
+ } catch (error) {
3644
+ console.log(chalk.red("\u274C Error checking daemon status"));
3645
+ }
3646
+ console.log(chalk.bold("\n\u{1F4DD} Log Files"));
3647
+ const mainLogs = getLogFiles(types$1.configuration.logsDir);
3648
+ if (mainLogs.length > 0) {
3649
+ console.log(chalk.blue("\nMain Logs:"));
3650
+ mainLogs.forEach(({ file, path, modified }) => {
3651
+ console.log(` ${chalk.green(file)} - ${modified.toLocaleString()}`);
3652
+ console.log(chalk.gray(` ${path}`));
3653
+ });
3654
+ } else {
3655
+ console.log(chalk.yellow("No main log files found"));
3656
+ }
3657
+ const daemonLogs = mainLogs.filter(({ file }) => file.includes("daemon"));
3658
+ if (daemonLogs.length > 0) {
3659
+ console.log(chalk.blue("\nDaemon Logs:"));
3660
+ daemonLogs.forEach(({ file, path, modified }) => {
3661
+ console.log(` ${chalk.green(file)} - ${modified.toLocaleString()}`);
3662
+ console.log(chalk.gray(` ${path}`));
3663
+ });
3664
+ } else {
3665
+ console.log(chalk.yellow("No daemon log files found"));
3666
+ }
3667
+ console.log(chalk.bold("\n\u{1F41B} Support & Bug Reports"));
3668
+ console.log(`Report issues: ${chalk.blue("https://github.com/slopus/happy-cli/issues")}`);
3669
+ console.log(`Documentation: ${chalk.blue("https://happy.engineering/")}`);
3670
+ console.log(chalk.green("\n\u2705 Doctor diagnosis complete!\n"));
3671
+ }
3672
+
3673
+ async function daemonPost(path, body) {
3674
+ const state = await getDaemonState();
3675
+ if (!state?.httpPort) {
3676
+ throw new Error("No daemon running");
3677
+ }
3678
+ try {
3679
+ const response = await fetch(`http://127.0.0.1:${state.httpPort}${path}`, {
3680
+ method: "POST",
3681
+ headers: { "Content-Type": "application/json" },
3682
+ body: JSON.stringify(body || {}),
3683
+ signal: AbortSignal.timeout(5e3)
3684
+ });
3685
+ if (!response.ok) {
3686
+ throw new Error(`HTTP ${response.status}`);
3687
+ }
3688
+ return await response.json();
3689
+ } catch (error) {
3690
+ types$1.logger.debug(`[CONTROL CLIENT] Request failed: ${path}`, error);
3691
+ throw error;
3692
+ }
3693
+ }
3694
+ async function notifyDaemonSessionStarted(sessionId, metadata) {
3695
+ await daemonPost("/session-started", {
3696
+ sessionId,
3697
+ metadata
3698
+ });
3699
+ }
3700
+ async function listDaemonSessions() {
3701
+ const result = await daemonPost("/list");
3702
+ return result.children || [];
3703
+ }
3704
+ async function stopDaemonSession(sessionId) {
3705
+ const result = await daemonPost("/stop-session", { sessionId });
3706
+ return result.success || false;
3707
+ }
3708
+ async function stopDaemonHttp() {
3709
+ await daemonPost("/stop");
3710
+ }
3711
+
3712
+ var controlClient = /*#__PURE__*/Object.freeze({
3713
+ __proto__: null,
3714
+ listDaemonSessions: listDaemonSessions,
3715
+ notifyDaemonSessionStarted: notifyDaemonSessionStarted,
3716
+ stopDaemonHttp: stopDaemonHttp,
3717
+ stopDaemonSession: stopDaemonSession
3718
+ });
3719
+
3204
3720
  async function start(credentials, options = {}) {
3205
3721
  const workingDirectory = process.cwd();
3206
3722
  const sessionTag = node_crypto.randomUUID();
3207
- if (options.daemonSpawn && options.startingMode === "local") {
3723
+ types$1.logger.debugLargeJson("[START] Happy process started", getEnvironmentInfo());
3724
+ types$1.logger.debug(`[START] Options: startedBy=${options.startedBy}, startingMode=${options.startingMode}`);
3725
+ if (options.startedBy === "daemon" && options.startingMode === "local") {
3208
3726
  types$1.logger.debug("Daemon spawn requested with local mode - forcing remote mode");
3209
3727
  options.startingMode = "remote";
3210
3728
  }
3211
3729
  const api = new types$1.ApiClient(credentials.token, credentials.secret);
3212
3730
  let state = {};
3213
- const settings = await readSettings() || { };
3731
+ const settings = await readSettings();
3732
+ const machineId = settings?.machineId || "unknown";
3733
+ types$1.logger.debug(`Using machineId: ${machineId}`);
3214
3734
  let metadata = {
3215
3735
  path: workingDirectory,
3216
3736
  host: os.hostname(),
3217
3737
  version: packageJson.version,
3218
3738
  os: os.platform(),
3219
- machineId: settings.machineId,
3220
- homeDir: os.homedir()
3739
+ machineId,
3740
+ homeDir: os.homedir(),
3741
+ happyHomeDir: types$1.configuration.happyHomeDir,
3742
+ startedFromDaemon: options.startedBy === "daemon",
3743
+ hostPid: process.pid,
3744
+ startedBy: options.startedBy || "terminal"
3221
3745
  };
3222
3746
  const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state });
3223
3747
  types$1.logger.debug(`Session created: ${response.id}`);
3748
+ try {
3749
+ const daemonState = await getDaemonState();
3750
+ if (daemonState?.httpPort) {
3751
+ await notifyDaemonSessionStarted(response.id, metadata);
3752
+ types$1.logger.debug(`[START] Reported session ${response.id} to daemon`);
3753
+ }
3754
+ } catch (error) {
3755
+ types$1.logger.debug("[START] Failed to report to daemon (may not be running):", error);
3756
+ }
3224
3757
  extractSDKMetadataAsync(async (sdkMetadata) => {
3225
3758
  types$1.logger.debug("[start] SDK metadata extracted, updating session:", sdkMetadata);
3226
3759
  try {
3227
- api.session(response).updateMetadata((currentMetadata) => ({
3760
+ api.sessionSyncClient(response).updateMetadata((currentMetadata) => ({
3228
3761
  ...currentMetadata,
3229
3762
  tools: sdkMetadata.tools,
3230
3763
  slashCommands: sdkMetadata.slashCommands
@@ -3234,10 +3767,7 @@ async function start(credentials, options = {}) {
3234
3767
  types$1.logger.debug("[start] Failed to update session metadata:", error);
3235
3768
  }
3236
3769
  });
3237
- if (options.daemonSpawn) {
3238
- console.log(`daemon:sessionIdCreated:${response.id}`);
3239
- }
3240
- const session = api.session(response);
3770
+ const session = api.sessionSyncClient(response);
3241
3771
  const logPath = await types$1.logger.logFilePathPromise;
3242
3772
  types$1.logger.infoDeveloper(`Session: ${response.id}`);
3243
3773
  types$1.logger.infoDeveloper(`Logs: ${logPath}`);
@@ -3363,6 +3893,32 @@ async function start(credentials, options = {}) {
3363
3893
  messageQueue.push(message.content.text, enhancedMode);
3364
3894
  types$1.logger.debugLargeJson("User message pushed to queue:", message);
3365
3895
  });
3896
+ const cleanup = async () => {
3897
+ types$1.logger.debug("[START] Received termination signal, cleaning up...");
3898
+ try {
3899
+ if (session) {
3900
+ session.sendSessionDeath();
3901
+ await session.flush();
3902
+ await session.close();
3903
+ }
3904
+ stopCaffeinate();
3905
+ types$1.logger.debug("[START] Cleanup complete, exiting");
3906
+ process.exit(0);
3907
+ } catch (error) {
3908
+ types$1.logger.debug("[START] Error during cleanup:", error);
3909
+ process.exit(1);
3910
+ }
3911
+ };
3912
+ process.on("SIGTERM", cleanup);
3913
+ process.on("SIGINT", cleanup);
3914
+ process.on("uncaughtException", (error) => {
3915
+ types$1.logger.debug("[START] Uncaught exception:", error);
3916
+ cleanup();
3917
+ });
3918
+ process.on("unhandledRejection", (reason) => {
3919
+ types$1.logger.debug("[START] Unhandled rejection:", reason);
3920
+ cleanup();
3921
+ });
3366
3922
  await loop({
3367
3923
  path: workingDirectory,
3368
3924
  model: options.model,
@@ -3472,10 +4028,14 @@ async function doAuth() {
3472
4028
  const secret = new Uint8Array(node_crypto.randomBytes(32));
3473
4029
  const keypair = tweetnacl.box.keyPair.fromSecretKey(secret);
3474
4030
  try {
4031
+ console.log(`[AUTH DEBUG] Sending auth request to: ${types$1.configuration.serverUrl}/v1/auth/request`);
4032
+ console.log(`[AUTH DEBUG] Public key: ${types$1.encodeBase64(keypair.publicKey).substring(0, 20)}...`);
3475
4033
  await axios.post(`${types$1.configuration.serverUrl}/v1/auth/request`, {
3476
4034
  publicKey: types$1.encodeBase64(keypair.publicKey)
3477
4035
  });
4036
+ console.log(`[AUTH DEBUG] Auth request sent successfully`);
3478
4037
  } catch (error) {
4038
+ console.log(`[AUTH DEBUG] Failed to send auth request:`, error);
3479
4039
  console.log("Failed to create authentication request, please try again later.");
3480
4040
  return null;
3481
4041
  }
@@ -3592,550 +4152,375 @@ function decryptWithEphemeralKey(encryptedBundle, recipientSecretKey) {
3592
4152
  }
3593
4153
  return decrypted;
3594
4154
  }
4155
+ async function authAndSetupMachineIfNeeded() {
4156
+ types$1.logger.debug("[AUTH] Starting auth and machine setup...");
4157
+ let credentials = await readCredentials();
4158
+ if (!credentials) {
4159
+ types$1.logger.debug("[AUTH] No credentials found, starting authentication flow...");
4160
+ const authResult = await doAuth();
4161
+ if (!authResult) {
4162
+ throw new Error("Authentication failed or was cancelled");
4163
+ }
4164
+ credentials = authResult;
4165
+ } else {
4166
+ types$1.logger.debug("[AUTH] Using existing credentials");
4167
+ }
4168
+ const settings = await updateSettings(async (s) => {
4169
+ if (!s.machineId) {
4170
+ return {
4171
+ ...s,
4172
+ machineId: node_crypto.randomUUID()
4173
+ };
4174
+ }
4175
+ return s;
4176
+ });
4177
+ types$1.logger.debug(`[AUTH] Machine ID: ${settings.machineId}`);
4178
+ return { credentials, machineId: settings.machineId };
4179
+ }
3595
4180
 
3596
- class ApiDaemonSession extends node_events.EventEmitter {
3597
- socket;
3598
- machineIdentity;
3599
- keepAliveInterval = null;
3600
- token;
3601
- secret;
3602
- spawnedProcesses = /* @__PURE__ */ new Set();
3603
- machineRegistered = false;
3604
- constructor(token, secret, machineIdentity) {
3605
- super();
3606
- this.token = token;
3607
- this.secret = secret;
3608
- this.machineIdentity = machineIdentity;
3609
- types$1.logger.debug(`[DAEMON SESSION] Connecting to server: ${types$1.configuration.serverUrl}`);
3610
- const socket = socket_ioClient.io(types$1.configuration.serverUrl, {
3611
- auth: {
3612
- token: this.token,
3613
- clientType: "machine-scoped",
3614
- machineId: this.machineIdentity.machineId
3615
- },
3616
- path: "/v1/updates",
3617
- reconnection: true,
3618
- reconnectionAttempts: Infinity,
3619
- reconnectionDelay: 1e3,
3620
- reconnectionDelayMax: 5e3,
3621
- transports: ["websocket"],
3622
- withCredentials: true,
3623
- autoConnect: false
3624
- });
3625
- socket.on("connect", async () => {
3626
- types$1.logger.debug("[DAEMON SESSION] Socket connected");
3627
- types$1.logger.debug(`[DAEMON SESSION] Connected with auth - token: ${this.token.substring(0, 10)}..., machineId: ${this.machineIdentity.machineId}`);
3628
- if (!this.machineRegistered) {
3629
- await this.registerMachine();
3630
- }
3631
- const rpcMethod = `${this.machineIdentity.machineId}:spawn-happy-session`;
3632
- socket.emit("rpc-register", { method: rpcMethod });
3633
- types$1.logger.debug(`[DAEMON SESSION] Emitted RPC registration: ${rpcMethod}`);
3634
- this.emit("connected");
3635
- this.startKeepAlive();
4181
+ function startDaemonControlServer({
4182
+ getChildren,
4183
+ stopSession,
4184
+ spawnSession,
4185
+ requestShutdown,
4186
+ onHappySessionWebhook
4187
+ }) {
4188
+ return new Promise((resolve) => {
4189
+ const app = fastify({
4190
+ logger: false
4191
+ // We use our own logger
3636
4192
  });
3637
- socket.on("rpc-request", async (data, callback) => {
3638
- types$1.logger.debug(`[DAEMON SESSION] Received RPC request: ${JSON.stringify(data)}`);
3639
- const expectedMethod = `${this.machineIdentity.machineId}:spawn-happy-session`;
3640
- if (data.method === expectedMethod) {
3641
- types$1.logger.debug("[DAEMON SESSION] Processing spawn-happy-session RPC");
3642
- try {
3643
- const { directory } = data.params || {};
3644
- if (!directory) {
3645
- throw new Error("Directory is required");
3646
- }
3647
- const args = [
3648
- "--daemon-spawn",
3649
- "--happy-starting-mode",
3650
- "remote"
3651
- // ALWAYS force remote mode for daemon spawns
3652
- ];
3653
- if (types$1.configuration.installationLocation === "local") {
3654
- args.push("--local");
3655
- }
3656
- types$1.logger.debug(`[DAEMON SESSION] Spawning happy in directory: ${directory} with args: ${args.join(" ")}`);
3657
- const happyBinPath = path.join(projectPath(), "bin", "happy.mjs");
3658
- types$1.logger.debug(`[DAEMON SESSION] Using happy binary at: ${happyBinPath}`);
3659
- const executable = happyBinPath;
3660
- const spawnArgs = args;
3661
- types$1.logger.debug(`[DAEMON SESSION] Spawn: executable=${executable}, args=${JSON.stringify(spawnArgs)}, cwd=${directory}`);
3662
- const happyProcess = child_process.spawn(executable, spawnArgs, {
3663
- cwd: directory,
3664
- detached: true,
3665
- stdio: ["ignore", "pipe", "pipe"]
3666
- // We need stdout
3667
- });
3668
- this.spawnedProcesses.add(happyProcess);
3669
- this.updateChildPidsInMetadata();
3670
- let sessionId = null;
3671
- let output = "";
3672
- let timeoutId = null;
3673
- const cleanup = () => {
3674
- happyProcess.stdout.removeAllListeners("data");
3675
- happyProcess.stderr.removeAllListeners("data");
3676
- happyProcess.removeAllListeners("error");
3677
- happyProcess.removeAllListeners("exit");
3678
- if (timeoutId) {
3679
- clearTimeout(timeoutId);
3680
- timeoutId = null;
3681
- }
3682
- };
3683
- happyProcess.stdout.on("data", (data2) => {
3684
- output += data2.toString();
3685
- const match = output.match(/daemon:sessionIdCreated:(.+?)[\n\r]/);
3686
- if (match && !sessionId) {
3687
- sessionId = match[1];
3688
- types$1.logger.debug(`[DAEMON SESSION] Session spawned successfully: ${sessionId}`);
3689
- callback({ sessionId });
3690
- cleanup();
3691
- happyProcess.unref();
3692
- }
3693
- });
3694
- happyProcess.stderr.on("data", (data2) => {
3695
- types$1.logger.debug(`[DAEMON SESSION] Spawned process stderr: ${data2.toString()}`);
3696
- });
3697
- happyProcess.on("error", (error) => {
3698
- types$1.logger.debug("[DAEMON SESSION] Error spawning session:", error);
3699
- if (!sessionId) {
3700
- callback({ error: `Failed to spawn: ${error.message}` });
3701
- cleanup();
3702
- this.spawnedProcesses.delete(happyProcess);
3703
- }
3704
- });
3705
- happyProcess.on("exit", (code, signal) => {
3706
- types$1.logger.debug(`[DAEMON SESSION] Spawned process exited with code ${code}, signal ${signal}`);
3707
- this.spawnedProcesses.delete(happyProcess);
3708
- this.updateChildPidsInMetadata();
3709
- if (!sessionId) {
3710
- callback({ error: `Process exited before session ID received` });
3711
- cleanup();
3712
- }
3713
- });
3714
- timeoutId = setTimeout(() => {
3715
- if (!sessionId) {
3716
- types$1.logger.debug("[DAEMON SESSION] Timeout waiting for session ID");
3717
- callback({ error: "Timeout waiting for session" });
3718
- cleanup();
3719
- happyProcess.kill();
3720
- this.spawnedProcesses.delete(happyProcess);
3721
- this.updateChildPidsInMetadata();
3722
- }
3723
- }, 1e4);
3724
- } catch (error) {
3725
- types$1.logger.debug("[DAEMON SESSION] Error spawning session:", error);
3726
- callback({ error: error instanceof Error ? error.message : "Unknown error" });
3727
- }
3728
- } else {
3729
- types$1.logger.debug(`[DAEMON SESSION] Unknown RPC method: ${data.method}`);
3730
- callback({ error: `Unknown method: ${data.method}` });
4193
+ app.setValidatorCompiler(fastifyTypeProviderZod.validatorCompiler);
4194
+ app.setSerializerCompiler(fastifyTypeProviderZod.serializerCompiler);
4195
+ const typed = app.withTypeProvider();
4196
+ typed.post("/session-started", {
4197
+ schema: {
4198
+ body: z.z.object({
4199
+ sessionId: z.z.string(),
4200
+ metadata: z.z.any()
4201
+ // Metadata type from API
4202
+ })
3731
4203
  }
4204
+ }, async (request, reply) => {
4205
+ const { sessionId, metadata } = request.body;
4206
+ types$1.logger.debug(`[CONTROL SERVER] Session started: ${sessionId}`);
4207
+ onHappySessionWebhook(sessionId, metadata);
4208
+ return { status: "ok" };
3732
4209
  });
3733
- socket.on("disconnect", (reason) => {
3734
- types$1.logger.debug(`[DAEMON SESSION] Disconnected from server. Reason: ${reason}`);
3735
- this.emit("disconnected");
3736
- this.stopKeepAlive();
3737
- });
3738
- socket.on("reconnect", () => {
3739
- types$1.logger.debug("[DAEMON SESSION] Reconnected to server");
3740
- const rpcMethod = `${this.machineIdentity.machineId}:spawn-happy-session`;
3741
- socket.emit("rpc-register", { method: rpcMethod });
3742
- types$1.logger.debug(`[DAEMON SESSION] Re-registered RPC method: ${rpcMethod}`);
4210
+ typed.post("/list", async (request, reply) => {
4211
+ const children = getChildren();
4212
+ types$1.logger.debug(`[CONTROL SERVER] Listing ${children.length} sessions`);
4213
+ return { children };
3743
4214
  });
3744
- socket.on("rpc-registered", (data) => {
3745
- types$1.logger.debug(`[DAEMON SESSION] RPC registration confirmed: ${data.method}`);
4215
+ typed.post("/stop-session", {
4216
+ schema: {
4217
+ body: z.z.object({
4218
+ sessionId: z.z.string()
4219
+ })
4220
+ }
4221
+ }, async (request, reply) => {
4222
+ const { sessionId } = request.body;
4223
+ types$1.logger.debug(`[CONTROL SERVER] Stop session request: ${sessionId}`);
4224
+ const success = stopSession(sessionId);
4225
+ return { success };
3746
4226
  });
3747
- socket.on("rpc-unregistered", (data) => {
3748
- types$1.logger.debug(`[DAEMON SESSION] RPC unregistered: ${data.method}`);
3749
- });
3750
- socket.on("rpc-error", (data) => {
3751
- types$1.logger.debug(`[DAEMON SESSION] RPC error: ${JSON.stringify(data)}`);
3752
- });
3753
- socket.onAny((event, ...args) => {
3754
- if (!event.startsWith("session-alive") && event !== "ephemeral") {
3755
- types$1.logger.debug(`[DAEMON SESSION] Socket event: ${event}, args: ${JSON.stringify(args)}`);
4227
+ typed.post("/spawn-session", {
4228
+ schema: {
4229
+ body: z.z.object({
4230
+ directory: z.z.string(),
4231
+ sessionId: z.z.string().optional()
4232
+ })
4233
+ }
4234
+ }, async (request, reply) => {
4235
+ const { directory, sessionId } = request.body;
4236
+ types$1.logger.debug(`[CONTROL SERVER] Spawn session request: dir=${directory}, sessionId=${sessionId || "new"}`);
4237
+ const session = await spawnSession(directory, sessionId);
4238
+ if (session) {
4239
+ return {
4240
+ success: true,
4241
+ pid: session.pid,
4242
+ sessionId: session.happySessionId || "pending"
4243
+ };
4244
+ } else {
4245
+ reply.code(500);
4246
+ return { error: "Failed to spawn session" };
3756
4247
  }
3757
4248
  });
3758
- socket.on("connect_error", (error) => {
3759
- types$1.logger.debug(`[DAEMON SESSION] Connection error: ${error.message}`);
3760
- types$1.logger.debug(`[DAEMON SESSION] Error: ${JSON.stringify(error, null, 2)}`);
3761
- });
3762
- socket.on("error", (error) => {
3763
- types$1.logger.debug(`[DAEMON SESSION] Socket error: ${error}`);
4249
+ typed.post("/stop", async (request, reply) => {
4250
+ types$1.logger.debug("[CONTROL SERVER] Stop daemon request received");
4251
+ setTimeout(() => {
4252
+ types$1.logger.debug("[CONTROL SERVER] Triggering daemon shutdown");
4253
+ requestShutdown();
4254
+ }, 50);
4255
+ return { status: "stopping" };
3764
4256
  });
3765
- socket.on("daemon-command", (data) => {
3766
- switch (data.command) {
3767
- case "shutdown":
3768
- this.shutdown();
3769
- break;
3770
- case "status":
3771
- this.emit("status-request");
3772
- break;
4257
+ app.listen({ port: 0, host: "127.0.0.1" }, (err, address) => {
4258
+ if (err) {
4259
+ types$1.logger.debug("[CONTROL SERVER] Failed to start:", err);
4260
+ throw err;
3773
4261
  }
3774
- });
3775
- this.socket = socket;
3776
- }
3777
- async registerMachine() {
3778
- try {
3779
- const metadata = {
3780
- host: this.machineIdentity.machineHost,
3781
- platform: this.machineIdentity.platform,
3782
- happyCliVersion: this.machineIdentity.happyCliVersion,
3783
- happyHomeDirectory: this.machineIdentity.happyHomeDirectory
3784
- };
3785
- const encrypted = types$1.encrypt(JSON.stringify(metadata), this.secret);
3786
- const encryptedMetadata = types$1.encodeBase64(encrypted);
3787
- const response = await fetch(`${types$1.configuration.serverUrl}/v1/machines`, {
3788
- method: "POST",
3789
- headers: {
3790
- "Authorization": `Bearer ${this.token}`,
3791
- "Content-Type": "application/json"
3792
- },
3793
- body: JSON.stringify({
3794
- id: this.machineIdentity.machineId,
3795
- metadata: encryptedMetadata
3796
- })
4262
+ const port = parseInt(address.split(":").pop());
4263
+ types$1.logger.debug(`[CONTROL SERVER] Started on port ${port}`);
4264
+ resolve({
4265
+ port,
4266
+ stop: async () => {
4267
+ types$1.logger.debug("[CONTROL SERVER] Stopping server");
4268
+ await app.close();
4269
+ types$1.logger.debug("[CONTROL SERVER] Server stopped");
4270
+ }
3797
4271
  });
3798
- if (response.ok) {
3799
- types$1.logger.debug("[DAEMON SESSION] Machine registered/updated successfully");
3800
- this.machineRegistered = true;
3801
- } else {
3802
- types$1.logger.debug(`[DAEMON SESSION] Failed to register machine: ${response.status}`);
3803
- }
3804
- } catch (error) {
3805
- types$1.logger.debug("[DAEMON SESSION] Failed to register machine:", error);
3806
- }
3807
- }
3808
- startKeepAlive() {
3809
- this.stopKeepAlive();
3810
- this.keepAliveInterval = setInterval(() => {
3811
- const payload = {
3812
- machineId: this.machineIdentity.machineId,
3813
- time: Date.now()
3814
- };
3815
- types$1.logger.debugLargeJson(`[DAEMON SESSION] Emitting machine-alive`, payload);
3816
- this.socket.emit("machine-alive", payload);
3817
- }, 2e4);
3818
- }
3819
- stopKeepAlive() {
3820
- if (this.keepAliveInterval) {
3821
- clearInterval(this.keepAliveInterval);
3822
- this.keepAliveInterval = null;
3823
- }
3824
- }
3825
- updateChildPidsInMetadata() {
3826
- try {
3827
- if (fs.existsSync(types$1.configuration.daemonMetadataFile)) {
3828
- const content = fs.readFileSync(types$1.configuration.daemonMetadataFile, "utf-8");
3829
- const metadata = JSON.parse(content);
3830
- const childPids = Array.from(this.spawnedProcesses).map((proc) => proc.pid).filter((pid) => pid !== void 0);
3831
- metadata.childPids = childPids;
3832
- fs.writeFileSync(types$1.configuration.daemonMetadataFile, JSON.stringify(metadata, null, 2));
3833
- }
3834
- } catch (error) {
3835
- types$1.logger.debug("[DAEMON SESSION] Error updating child PIDs in metadata:", error);
3836
- }
3837
- }
3838
- connect() {
3839
- this.socket.connect();
3840
- }
3841
- updateMetadata(updates) {
3842
- const metadata = {
3843
- host: this.machineIdentity.machineHost,
3844
- platform: this.machineIdentity.platform,
3845
- happyCliVersion: updates.happyCliVersion || this.machineIdentity.happyCliVersion,
3846
- happyHomeDirectory: updates.happyHomeDirectory || this.machineIdentity.happyHomeDirectory
3847
- };
3848
- const encrypted = types$1.encrypt(JSON.stringify(metadata), this.secret);
3849
- const encryptedMetadata = types$1.encodeBase64(encrypted);
3850
- this.socket.emit("update-machine", { metadata: encryptedMetadata });
3851
- }
3852
- shutdown() {
3853
- types$1.logger.debug(`[DAEMON SESSION] Shutting down daemon, killing ${this.spawnedProcesses.size} spawned processes`);
3854
- for (const process of this.spawnedProcesses) {
3855
- try {
3856
- types$1.logger.debug(`[DAEMON SESSION] Killing spawned process with PID: ${process.pid}`);
3857
- process.kill("SIGTERM");
3858
- setTimeout(() => {
3859
- try {
3860
- process.kill("SIGKILL");
3861
- } catch (e) {
3862
- }
3863
- }, 1e3);
3864
- } catch (error) {
3865
- types$1.logger.debug(`[DAEMON SESSION] Error killing process: ${error}`);
3866
- }
3867
- }
3868
- this.spawnedProcesses.clear();
3869
- this.updateChildPidsInMetadata();
3870
- this.stopKeepAlive();
3871
- this.socket.close();
3872
- this.emit("shutdown");
3873
- }
4272
+ });
4273
+ });
3874
4274
  }
3875
4275
 
3876
4276
  async function startDaemon() {
3877
- if (process.platform !== "darwin") {
3878
- console.error("ERROR: Daemon is only supported on macOS");
3879
- process.exit(1);
3880
- }
3881
4277
  types$1.logger.debug("[DAEMON RUN] Starting daemon process...");
3882
- types$1.logger.debug(`[DAEMON RUN] Server URL: ${types$1.configuration.serverUrl}`);
3883
- const runningDaemon = await getDaemonMetadata();
4278
+ types$1.logger.debugLargeJson("[DAEMON RUN] Environment", getEnvironmentInfo());
4279
+ const runningDaemon = await getDaemonState();
3884
4280
  if (runningDaemon) {
3885
- if (runningDaemon.version !== packageJson.version) {
3886
- types$1.logger.debug(`[DAEMON RUN] Daemon version mismatch (running: ${runningDaemon.version}, current: ${packageJson.version}), restarting...`);
3887
- await stopDaemon();
3888
- await new Promise((resolve) => setTimeout(resolve, 500));
3889
- } else if (await isDaemonProcessRunning(runningDaemon.pid)) {
3890
- types$1.logger.debug("[DAEMON RUN] Happy daemon is already running with correct version");
4281
+ try {
4282
+ process.kill(runningDaemon.pid, 0);
4283
+ types$1.logger.debug("[DAEMON RUN] Daemon already running");
3891
4284
  process.exit(0);
3892
- } else {
3893
- types$1.logger.debug("[DAEMON RUN] Stale daemon metadata found, cleaning up");
3894
- await cleanupDaemonMetadata();
3895
- }
3896
- }
3897
- const oldMetadata = await getDaemonMetadata();
3898
- if (oldMetadata && oldMetadata.childPids && oldMetadata.childPids.length > 0) {
3899
- types$1.logger.debug(`[DAEMON RUN] Found ${oldMetadata.childPids.length} potential orphaned child processes from previous run`);
3900
- for (const childPid of oldMetadata.childPids) {
3901
- try {
3902
- process.kill(childPid, 0);
3903
- const isHappy = await isProcessHappyChild(childPid);
3904
- if (isHappy) {
3905
- types$1.logger.debug(`[DAEMON RUN] Killing orphaned happy process ${childPid}`);
3906
- process.kill(childPid, "SIGTERM");
3907
- await new Promise((resolve) => setTimeout(resolve, 500));
3908
- try {
3909
- process.kill(childPid, 0);
3910
- process.kill(childPid, "SIGKILL");
3911
- } catch {
3912
- }
3913
- }
3914
- } catch {
3915
- types$1.logger.debug(`[DAEMON RUN] Process ${childPid} doesn't exist (already dead)`);
3916
- }
4285
+ } catch {
4286
+ types$1.logger.debug("[DAEMON RUN] Stale state found, cleaning up");
4287
+ await cleanupDaemonState();
3917
4288
  }
3918
4289
  }
3919
- writeDaemonMetadata();
3920
- types$1.logger.debug("[DAEMON RUN] Daemon metadata written");
3921
4290
  const caffeinateStarted = startCaffeinate();
3922
4291
  if (caffeinateStarted) {
3923
- types$1.logger.debug("[DAEMON RUN] Sleep prevention enabled for daemon");
4292
+ types$1.logger.debug("[DAEMON RUN] Sleep prevention enabled");
3924
4293
  }
3925
4294
  try {
3926
- const settings = await readSettings() || { onboardingCompleted: false };
3927
- if (!settings.machineId) {
3928
- settings.machineId = crypto.randomUUID();
3929
- settings.machineHost = os$1.hostname();
3930
- await writeSettings(settings);
3931
- }
3932
- const machineIdentity = {
3933
- machineId: settings.machineId,
3934
- machineHost: settings.machineHost || os$1.hostname(),
3935
- platform: process.platform,
3936
- happyCliVersion: packageJson.version,
3937
- happyHomeDirectory: process.cwd()
3938
- };
3939
- let credentials = await readCredentials();
3940
- if (!credentials) {
3941
- types$1.logger.debug("[DAEMON RUN] No credentials found, running auth");
3942
- await doAuth();
3943
- credentials = await readCredentials();
3944
- if (!credentials) {
3945
- throw new Error("Failed to authenticate");
3946
- }
3947
- }
3948
- const { token, secret } = credentials;
3949
- const daemon = new ApiDaemonSession(
3950
- token,
3951
- secret,
3952
- machineIdentity
3953
- );
3954
- daemon.on("connected", () => {
3955
- types$1.logger.debug("[DAEMON RUN] Connected to server event received");
3956
- });
3957
- daemon.on("disconnected", () => {
3958
- types$1.logger.debug("[DAEMON RUN] Disconnected from server event received");
3959
- });
3960
- daemon.on("shutdown", () => {
3961
- types$1.logger.debug("[DAEMON RUN] Shutdown requested");
3962
- daemon?.shutdown();
3963
- cleanupDaemonMetadata();
3964
- process.exit(0);
3965
- });
3966
- daemon.connect();
3967
- types$1.logger.debug("[DAEMON RUN] Daemon started successfully");
3968
- process.on("SIGINT", async () => {
3969
- types$1.logger.debug("[DAEMON RUN] Received SIGINT, shutting down...");
3970
- if (daemon) {
3971
- daemon.shutdown();
4295
+ const { credentials, machineId } = await authAndSetupMachineIfNeeded();
4296
+ types$1.logger.debug("[DAEMON RUN] Auth and machine setup complete");
4297
+ const pidToTrackedSession = /* @__PURE__ */ new Map();
4298
+ const pidToAwaiter = /* @__PURE__ */ new Map();
4299
+ const getCurrentChildren = () => Array.from(pidToTrackedSession.values());
4300
+ const onHappySessionWebhook = (sessionId, sessionMetadata) => {
4301
+ const pid = sessionMetadata.hostPid;
4302
+ if (!pid) {
4303
+ types$1.logger.debug(`[DAEMON RUN] Session webhook missing hostPid for session ${sessionId}`);
4304
+ return;
3972
4305
  }
3973
- await cleanupDaemonMetadata();
3974
- process.exit(0);
3975
- });
3976
- process.on("SIGTERM", async () => {
3977
- types$1.logger.debug("[DAEMON RUN] Received SIGTERM, shutting down...");
3978
- if (daemon) {
3979
- daemon.shutdown();
4306
+ types$1.logger.debug(`[DAEMON RUN] Session webhook: ${sessionId}, PID: ${pid}, started by: ${sessionMetadata.startedBy || "unknown"}`);
4307
+ const existingSession = pidToTrackedSession.get(pid);
4308
+ if (existingSession && existingSession.startedBy === "daemon") {
4309
+ existingSession.happySessionId = sessionId;
4310
+ existingSession.happySessionMetadataFromLocalWebhook = sessionMetadata;
4311
+ types$1.logger.debug(`[DAEMON RUN] Updated daemon-spawned session ${sessionId} with metadata`);
4312
+ const awaiter = pidToAwaiter.get(pid);
4313
+ if (awaiter) {
4314
+ pidToAwaiter.delete(pid);
4315
+ awaiter(existingSession);
4316
+ types$1.logger.debug(`[DAEMON RUN] Resolved session awaiter for PID ${pid}`);
4317
+ }
4318
+ } else if (!existingSession) {
4319
+ const trackedSession = {
4320
+ startedBy: "happy directly - likely by user from terminal",
4321
+ happySessionId: sessionId,
4322
+ happySessionMetadataFromLocalWebhook: sessionMetadata,
4323
+ pid
4324
+ };
4325
+ pidToTrackedSession.set(pid, trackedSession);
4326
+ types$1.logger.debug(`[DAEMON RUN] Registered externally-started session ${sessionId}`);
3980
4327
  }
3981
- await cleanupDaemonMetadata();
3982
- process.exit(0);
3983
- });
3984
- } catch (error) {
3985
- types$1.logger.debug("[DAEMON RUN] Failed to start daemon", error);
3986
- stopDaemon();
3987
- process.exit(1);
3988
- }
3989
- while (true) {
3990
- await new Promise((resolve) => setTimeout(resolve, 1e3));
3991
- }
3992
- }
3993
- async function isDaemonRunning() {
3994
- try {
3995
- types$1.logger.debug("[DAEMON RUN] [isDaemonRunning] Checking if daemon is running...");
3996
- const metadata = await getDaemonMetadata();
3997
- if (!metadata) {
3998
- types$1.logger.debug("[DAEMON RUN] [isDaemonRunning] No daemon metadata found");
3999
- return false;
4000
- }
4001
- types$1.logger.debug("[DAEMON RUN] [isDaemonRunning] Daemon metadata exists");
4002
- types$1.logger.debug("[DAEMON RUN] [isDaemonRunning] PID from metadata:", metadata.pid);
4003
- const isRunning = await isDaemonProcessRunning(metadata.pid);
4004
- if (!isRunning) {
4005
- types$1.logger.debug("[DAEMON RUN] [isDaemonRunning] Process not running, cleaning up stale metadata");
4006
- await cleanupDaemonMetadata();
4007
- return false;
4008
- }
4009
- return true;
4010
- } catch (error) {
4011
- types$1.logger.debug("[DAEMON RUN] [isDaemonRunning] Error:", error);
4012
- types$1.logger.debug("Error checking daemon status", error);
4013
- return false;
4014
- }
4015
- }
4016
- async function isDaemonProcessRunning(pid) {
4017
- try {
4018
- process.kill(pid, 0);
4019
- types$1.logger.debug("[DAEMON RUN] Process exists, checking if it's a happy daemon...");
4020
- const isHappyDaemon = await isProcessHappyDaemon(pid);
4021
- types$1.logger.debug("[DAEMON RUN] isHappyDaemon:", isHappyDaemon);
4022
- return isHappyDaemon;
4023
- } catch (error) {
4024
- return false;
4025
- }
4026
- }
4027
- function writeDaemonMetadata(childPids) {
4028
- const happyDir = path.join(os$1.homedir(), ".happy");
4029
- if (!fs.existsSync(happyDir)) {
4030
- fs.mkdirSync(happyDir, { recursive: true });
4031
- }
4032
- const metadata = {
4033
- pid: process.pid,
4034
- startTime: (/* @__PURE__ */ new Date()).toISOString(),
4035
- version: packageJson.version,
4036
- ...childPids
4037
- };
4038
- fs.writeFileSync(types$1.configuration.daemonMetadataFile, JSON.stringify(metadata, null, 2));
4039
- }
4040
- async function getDaemonMetadata() {
4041
- try {
4042
- if (!fs.existsSync(types$1.configuration.daemonMetadataFile)) {
4043
- return null;
4044
- }
4045
- const content = fs.readFileSync(types$1.configuration.daemonMetadataFile, "utf-8");
4046
- return JSON.parse(content);
4047
- } catch (error) {
4048
- types$1.logger.debug("Error reading daemon metadata", error);
4049
- return null;
4050
- }
4051
- }
4052
- async function cleanupDaemonMetadata() {
4053
- try {
4054
- if (fs.existsSync(types$1.configuration.daemonMetadataFile)) {
4055
- fs.unlinkSync(types$1.configuration.daemonMetadataFile);
4056
- }
4057
- } catch (error) {
4058
- types$1.logger.debug("Error cleaning up daemon metadata", error);
4059
- }
4060
- }
4061
- async function stopDaemon() {
4062
- try {
4063
- stopCaffeinate();
4064
- types$1.logger.debug("Stopped sleep prevention");
4065
- const metadata = await getDaemonMetadata();
4066
- if (metadata) {
4067
- types$1.logger.debug(`Stopping daemon with PID ${metadata.pid}`);
4328
+ };
4329
+ const spawnSession = async (directory, sessionId) => {
4068
4330
  try {
4069
- process.kill(metadata.pid, "SIGTERM");
4070
- await new Promise((resolve) => setTimeout(resolve, 2e3));
4071
- try {
4072
- process.kill(metadata.pid, 0);
4073
- types$1.logger.debug("Daemon still running, force killing...");
4074
- process.kill(metadata.pid, "SIGKILL");
4075
- } catch {
4076
- types$1.logger.debug("Daemon exited cleanly");
4331
+ const happyBinPath = path.join(projectPath(), "bin", "happy.mjs");
4332
+ const args = [
4333
+ "--happy-starting-mode",
4334
+ "remote",
4335
+ "--started-by",
4336
+ "daemon"
4337
+ ];
4338
+ const fullCommand = `${happyBinPath} ${args.join(" ")}`;
4339
+ types$1.logger.debug(`[DAEMON RUN] Spawning: ${fullCommand} in ${directory}`);
4340
+ const happyProcess = child_process.spawn(happyBinPath, args, {
4341
+ cwd: directory,
4342
+ detached: true,
4343
+ // Sessions stay alive when daemon stops
4344
+ stdio: ["ignore", "pipe", "pipe"]
4345
+ // Capture stdout/stderr for debugging
4346
+ // env is inherited automatically from parent process
4347
+ });
4348
+ happyProcess.stdout?.on("data", (data) => {
4349
+ types$1.logger.debug(`[DAEMON RUN] Child stdout: ${data.toString()}`);
4350
+ });
4351
+ happyProcess.stderr?.on("data", (data) => {
4352
+ types$1.logger.debug(`[DAEMON RUN] Child stderr: ${data.toString()}`);
4353
+ });
4354
+ if (!happyProcess.pid) {
4355
+ types$1.logger.debug("[DAEMON RUN] Failed to spawn process - no PID returned");
4356
+ return null;
4077
4357
  }
4358
+ types$1.logger.debug(`[DAEMON RUN] Spawned process with PID ${happyProcess.pid}`);
4359
+ const trackedSession = {
4360
+ startedBy: "daemon",
4361
+ pid: happyProcess.pid,
4362
+ childProcess: happyProcess
4363
+ };
4364
+ pidToTrackedSession.set(happyProcess.pid, trackedSession);
4365
+ happyProcess.on("exit", (code, signal) => {
4366
+ types$1.logger.debug(`[DAEMON RUN] Child PID ${happyProcess.pid} exited with code ${code}, signal ${signal}`);
4367
+ if (happyProcess.pid) {
4368
+ onChildExited(happyProcess.pid);
4369
+ }
4370
+ });
4371
+ happyProcess.on("error", (error) => {
4372
+ types$1.logger.debug(`[DAEMON RUN] Child process error:`, error);
4373
+ if (happyProcess.pid) {
4374
+ onChildExited(happyProcess.pid);
4375
+ }
4376
+ });
4377
+ types$1.logger.debug(`[DAEMON RUN] Waiting for session webhook for PID ${happyProcess.pid}`);
4378
+ return new Promise((resolve, reject) => {
4379
+ const timeout = setTimeout(() => {
4380
+ pidToAwaiter.delete(happyProcess.pid);
4381
+ types$1.logger.debug(`[DAEMON RUN] Session webhook timeout for PID ${happyProcess.pid}`);
4382
+ resolve(trackedSession);
4383
+ }, 1e4);
4384
+ pidToAwaiter.set(happyProcess.pid, (completedSession) => {
4385
+ clearTimeout(timeout);
4386
+ types$1.logger.debug(`[DAEMON RUN] Session ${completedSession.happySessionId} fully spawned with webhook`);
4387
+ resolve(completedSession);
4388
+ });
4389
+ });
4078
4390
  } catch (error) {
4079
- types$1.logger.debug("Daemon process already dead or inaccessible", error);
4391
+ types$1.logger.debug("[DAEMON RUN] Failed to spawn session:", error);
4392
+ return null;
4080
4393
  }
4081
- await new Promise((resolve) => setTimeout(resolve, 500));
4082
- if (metadata.childPids && metadata.childPids.length > 0) {
4083
- types$1.logger.debug(`Checking for ${metadata.childPids.length} potential orphaned child processes...`);
4084
- for (const childPid of metadata.childPids) {
4085
- try {
4086
- process.kill(childPid, 0);
4087
- const isHappy = await isProcessHappyChild(childPid);
4088
- if (isHappy) {
4089
- types$1.logger.debug(`Killing orphaned happy process ${childPid}`);
4090
- process.kill(childPid, "SIGTERM");
4091
- await new Promise((resolve) => setTimeout(resolve, 500));
4092
- try {
4093
- process.kill(childPid, 0);
4094
- process.kill(childPid, "SIGKILL");
4095
- } catch {
4096
- }
4394
+ };
4395
+ const stopSession = (sessionId) => {
4396
+ types$1.logger.debug(`[DAEMON RUN] Attempting to stop session ${sessionId}`);
4397
+ for (const [pid, session] of pidToTrackedSession.entries()) {
4398
+ if (session.happySessionId === sessionId || sessionId.startsWith("PID-") && pid === parseInt(sessionId.replace("PID-", ""))) {
4399
+ if (session.startedBy === "daemon" && session.childProcess) {
4400
+ try {
4401
+ session.childProcess.kill("SIGTERM");
4402
+ types$1.logger.debug(`[DAEMON RUN] Sent SIGTERM to daemon-spawned session ${sessionId}`);
4403
+ } catch (error) {
4404
+ types$1.logger.debug(`[DAEMON RUN] Failed to kill session ${sessionId}:`, error);
4405
+ }
4406
+ } else {
4407
+ try {
4408
+ process.kill(pid, "SIGTERM");
4409
+ types$1.logger.debug(`[DAEMON RUN] Sent SIGTERM to external session PID ${pid}`);
4410
+ } catch (error) {
4411
+ types$1.logger.debug(`[DAEMON RUN] Failed to kill external session PID ${pid}:`, error);
4097
4412
  }
4098
- } catch {
4099
4413
  }
4414
+ pidToTrackedSession.delete(pid);
4415
+ types$1.logger.debug(`[DAEMON RUN] Removed session ${sessionId} from tracking`);
4416
+ return true;
4100
4417
  }
4101
4418
  }
4102
- await cleanupDaemonMetadata();
4103
- }
4104
- } catch (error) {
4105
- types$1.logger.debug("Error stopping daemon", error);
4106
- }
4107
- }
4108
- async function isProcessHappyDaemon(pid) {
4109
- return new Promise((resolve) => {
4110
- const ps = child_process.spawn("ps", ["-p", pid.toString(), "-o", "command="]);
4111
- let output = "";
4112
- ps.stdout.on("data", (data) => {
4113
- output += data.toString();
4419
+ types$1.logger.debug(`[DAEMON RUN] Session ${sessionId} not found`);
4420
+ return false;
4421
+ };
4422
+ const onChildExited = (pid) => {
4423
+ types$1.logger.debug(`[DAEMON RUN] Removing exited process PID ${pid} from tracking`);
4424
+ pidToTrackedSession.delete(pid);
4425
+ };
4426
+ let requestShutdown;
4427
+ let resolvesWhenShutdownRequested = new Promise((resolve) => {
4428
+ requestShutdown = resolve;
4429
+ });
4430
+ const { port: controlPort, stop: stopControlServer } = await startDaemonControlServer({
4431
+ getChildren: getCurrentChildren,
4432
+ stopSession,
4433
+ spawnSession,
4434
+ requestShutdown: () => requestShutdown("happy-cli"),
4435
+ onHappySessionWebhook
4114
4436
  });
4115
- ps.on("close", () => {
4116
- const isHappyDaemon = output.includes("daemon start") && (output.includes("happy") || output.includes("src/index"));
4117
- resolve(isHappyDaemon);
4437
+ const fileState = {
4438
+ pid: process.pid,
4439
+ httpPort: controlPort,
4440
+ startTime: (/* @__PURE__ */ new Date()).toISOString(),
4441
+ startedWithCliVersion: packageJson.version
4442
+ };
4443
+ await writeDaemonState(fileState);
4444
+ types$1.logger.debug("[DAEMON RUN] Daemon state written");
4445
+ const initialMetadata = {
4446
+ host: os$1.hostname(),
4447
+ platform: os$1.platform(),
4448
+ happyCliVersion: packageJson.version,
4449
+ homeDir: os$1.homedir(),
4450
+ happyHomeDir: types$1.configuration.happyHomeDir
4451
+ };
4452
+ const initialDaemonState = {
4453
+ status: "offline",
4454
+ pid: process.pid,
4455
+ httpPort: controlPort,
4456
+ startedAt: Date.now()
4457
+ };
4458
+ const api = new types$1.ApiClient(credentials.token, credentials.secret);
4459
+ const machine = await api.createOrReturnExistingAsIs({
4460
+ machineId,
4461
+ metadata: initialMetadata,
4462
+ daemonState: initialDaemonState
4118
4463
  });
4119
- ps.on("error", () => {
4120
- resolve(false);
4464
+ types$1.logger.debug(`[DAEMON RUN] Machine registered: ${machine.id}`);
4465
+ const apiMachine = api.machineSyncClient(machine);
4466
+ apiMachine.setRPCHandlers({
4467
+ spawnSession,
4468
+ stopSession,
4469
+ requestShutdown: () => requestShutdown("happy-app")
4121
4470
  });
4122
- });
4123
- }
4124
- async function isProcessHappyChild(pid) {
4125
- return new Promise((resolve) => {
4126
- const ps = child_process.spawn("ps", ["-p", pid.toString(), "-o", "command="]);
4127
- let output = "";
4128
- ps.stdout.on("data", (data) => {
4129
- output += data.toString();
4471
+ apiMachine.connect();
4472
+ const cleanupAndShutdown = async (source) => {
4473
+ types$1.logger.debug(`[DAEMON RUN] Starting cleanup (source: ${source})...`);
4474
+ if (apiMachine) {
4475
+ await apiMachine.updateDaemonState((state) => ({
4476
+ ...state,
4477
+ status: "shutting-down",
4478
+ shutdownRequestedAt: Date.now(),
4479
+ shutdownSource: source === "happy-app" ? "mobile-app" : source === "happy-cli" ? "cli" : source
4480
+ }));
4481
+ await new Promise((resolve) => setTimeout(resolve, 100));
4482
+ }
4483
+ if (apiMachine) {
4484
+ apiMachine.shutdown();
4485
+ }
4486
+ types$1.logger.debug("[DAEMON RUN] Machine session shutdown");
4487
+ await stopControlServer();
4488
+ types$1.logger.debug("[DAEMON RUN] Control server stopped");
4489
+ await cleanupDaemonState();
4490
+ types$1.logger.debug("[DAEMON RUN] State cleaned up");
4491
+ stopCaffeinate();
4492
+ types$1.logger.debug("[DAEMON RUN] Caffeinate stopped");
4493
+ process.exit(0);
4494
+ };
4495
+ process.on("SIGINT", () => {
4496
+ types$1.logger.debug("[DAEMON RUN] Received SIGINT");
4497
+ cleanupAndShutdown("os-signal");
4130
4498
  });
4131
- ps.on("close", () => {
4132
- const isHappyChild = output.includes("--daemon-spawn") && (output.includes("happy") || output.includes("src/index"));
4133
- resolve(isHappyChild);
4499
+ process.on("SIGTERM", () => {
4500
+ types$1.logger.debug("[DAEMON RUN] Received SIGTERM");
4501
+ cleanupAndShutdown("os-signal");
4134
4502
  });
4135
- ps.on("error", () => {
4136
- resolve(false);
4503
+ process.on("uncaughtException", (error) => {
4504
+ types$1.logger.debug("[DAEMON RUN] Uncaught exception - cleaning up before crash", error);
4505
+ cleanupAndShutdown("unknown");
4137
4506
  });
4138
- });
4507
+ process.on("unhandledRejection", (reason) => {
4508
+ types$1.logger.debug("[DAEMON RUN] Unhandled rejection - cleaning up before crash", reason);
4509
+ cleanupAndShutdown("unknown");
4510
+ });
4511
+ process.on("exit", () => {
4512
+ types$1.logger.debug("[DAEMON RUN] Process exit, not killing any children");
4513
+ });
4514
+ types$1.logger.debug("[DAEMON RUN] Daemon started successfully");
4515
+ const shutdownSource = await resolvesWhenShutdownRequested;
4516
+ types$1.logger.debug(`[DAEMON RUN] Shutdown requested (source: ${shutdownSource})`);
4517
+ await cleanupAndShutdown(shutdownSource);
4518
+ } catch (error) {
4519
+ types$1.logger.debug("[DAEMON RUN] Failed to start daemon", error);
4520
+ await cleanupDaemonState();
4521
+ stopCaffeinate();
4522
+ process.exit(1);
4523
+ }
4139
4524
  }
4140
4525
 
4141
4526
  function trimIdent(text) {
@@ -4159,7 +4544,6 @@ function trimIdent(text) {
4159
4544
 
4160
4545
  const PLIST_LABEL$1 = "com.happy-cli.daemon";
4161
4546
  const PLIST_FILE$1 = `/Library/LaunchDaemons/${PLIST_LABEL$1}.plist`;
4162
- const USER_HOME = process.env.HOME || process.env.USERPROFILE;
4163
4547
  async function install$1() {
4164
4548
  try {
4165
4549
  if (fs.existsSync(PLIST_FILE$1)) {
@@ -4196,10 +4580,10 @@ async function install$1() {
4196
4580
  <true/>
4197
4581
 
4198
4582
  <key>StandardErrorPath</key>
4199
- <string>${USER_HOME}/.happy/daemon.err</string>
4583
+ <string>${os$1.homedir()}/.happy/daemon.err</string>
4200
4584
 
4201
4585
  <key>StandardOutPath</key>
4202
- <string>${USER_HOME}/.happy/daemon.log</string>
4586
+ <string>${os$1.homedir()}/.happy/daemon.log</string>
4203
4587
 
4204
4588
  <key>WorkingDirectory</key>
4205
4589
  <string>/tmp</string>
@@ -4263,16 +4647,249 @@ async function uninstall() {
4263
4647
  await uninstall$1();
4264
4648
  }
4265
4649
 
4650
+ const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
4651
+ function bytesToBase32(bytes) {
4652
+ let result = "";
4653
+ let buffer = 0;
4654
+ let bufferLength = 0;
4655
+ for (const byte of bytes) {
4656
+ buffer = buffer << 8 | byte;
4657
+ bufferLength += 8;
4658
+ while (bufferLength >= 5) {
4659
+ bufferLength -= 5;
4660
+ result += BASE32_ALPHABET[buffer >> bufferLength & 31];
4661
+ }
4662
+ }
4663
+ if (bufferLength > 0) {
4664
+ result += BASE32_ALPHABET[buffer << 5 - bufferLength & 31];
4665
+ }
4666
+ return result;
4667
+ }
4668
+ function formatSecretKeyForBackup(secretBytes) {
4669
+ const base32 = bytesToBase32(secretBytes);
4670
+ const groups = [];
4671
+ for (let i = 0; i < base32.length; i += 5) {
4672
+ groups.push(base32.slice(i, i + 5));
4673
+ }
4674
+ return groups.join("-");
4675
+ }
4676
+
4677
+ async function handleAuthCommand(args) {
4678
+ const subcommand = args[0];
4679
+ if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
4680
+ showAuthHelp();
4681
+ return;
4682
+ }
4683
+ switch (subcommand) {
4684
+ case "login":
4685
+ await handleAuthLogin(args.slice(1));
4686
+ break;
4687
+ case "logout":
4688
+ await handleAuthLogout();
4689
+ break;
4690
+ case "show-backup":
4691
+ await handleAuthShowBackup();
4692
+ break;
4693
+ case "status":
4694
+ await handleAuthStatus();
4695
+ break;
4696
+ default:
4697
+ console.error(chalk.red(`Unknown auth subcommand: ${subcommand}`));
4698
+ showAuthHelp();
4699
+ process.exit(1);
4700
+ }
4701
+ }
4702
+ function showAuthHelp() {
4703
+ console.log(`
4704
+ ${chalk.bold("happy auth")} - Authentication management
4705
+
4706
+ ${chalk.bold("Usage:")}
4707
+ happy auth login [--force] Authenticate with Happy
4708
+ happy auth logout Remove authentication and machine data
4709
+ happy auth status Show authentication status
4710
+ happy auth show-backup Display backup key for mobile/web clients
4711
+ happy auth help Show this help message
4712
+
4713
+ ${chalk.bold("Options:")}
4714
+ --force Clear credentials, machine ID, and stop daemon before re-auth
4715
+
4716
+ ${chalk.bold("Examples:")}
4717
+ happy auth login Authenticate if not already logged in
4718
+ happy auth login --force Force re-authentication (complete reset)
4719
+ happy auth status Check authentication and machine status
4720
+ happy auth show-backup Get backup key to link other devices
4721
+ happy auth logout Remove all authentication data
4722
+
4723
+ ${chalk.bold("Notes:")}
4724
+ \u2022 Use 'auth login --force' when you need to re-register your machine
4725
+ \u2022 'auth show-backup' displays the key format expected by mobile/web clients
4726
+ \u2022 The backup key allows linking multiple devices to the same account
4727
+ `);
4728
+ }
4729
+ async function handleAuthLogin(args) {
4730
+ const forceAuth = args.includes("--force") || args.includes("-f");
4731
+ if (forceAuth) {
4732
+ console.log(chalk.yellow("Force authentication requested."));
4733
+ console.log(chalk.gray("This will:"));
4734
+ console.log(chalk.gray(" \u2022 Clear existing credentials"));
4735
+ console.log(chalk.gray(" \u2022 Clear machine ID"));
4736
+ console.log(chalk.gray(" \u2022 Stop daemon if running"));
4737
+ console.log(chalk.gray(" \u2022 Re-authenticate and register machine\n"));
4738
+ try {
4739
+ types$1.logger.debug("Stopping daemon for force auth...");
4740
+ await stopDaemon();
4741
+ console.log(chalk.gray("\u2713 Stopped daemon"));
4742
+ } catch (error) {
4743
+ types$1.logger.debug("Daemon was not running or failed to stop:", error);
4744
+ }
4745
+ await clearCredentials();
4746
+ console.log(chalk.gray("\u2713 Cleared credentials"));
4747
+ await clearMachineId();
4748
+ console.log(chalk.gray("\u2713 Cleared machine ID"));
4749
+ console.log("");
4750
+ }
4751
+ if (!forceAuth) {
4752
+ const existingCreds = await readCredentials();
4753
+ const settings = await readSettings();
4754
+ if (existingCreds && settings?.machineId) {
4755
+ console.log(chalk.green("\u2713 Already authenticated"));
4756
+ console.log(chalk.gray(` Machine ID: ${settings.machineId}`));
4757
+ console.log(chalk.gray(` Host: ${os.hostname()}`));
4758
+ console.log(chalk.gray(` Use 'happy auth login --force' to re-authenticate`));
4759
+ return;
4760
+ } else if (existingCreds && !settings?.machineId) {
4761
+ console.log(chalk.yellow("\u26A0\uFE0F Credentials exist but machine ID is missing"));
4762
+ console.log(chalk.gray(" This can happen if --auth flag was used previously"));
4763
+ console.log(chalk.gray(" Fixing by setting up machine...\n"));
4764
+ }
4765
+ }
4766
+ try {
4767
+ const result = await authAndSetupMachineIfNeeded();
4768
+ console.log(chalk.green("\n\u2713 Authentication successful"));
4769
+ console.log(chalk.gray(` Machine ID: ${result.machineId}`));
4770
+ } catch (error) {
4771
+ console.error(chalk.red("Authentication failed:"), error instanceof Error ? error.message : "Unknown error");
4772
+ process.exit(1);
4773
+ }
4774
+ }
4775
+ async function handleAuthLogout() {
4776
+ const happyDir = types$1.configuration.happyHomeDir;
4777
+ const credentials = await readCredentials();
4778
+ if (!credentials) {
4779
+ console.log(chalk.yellow("Not currently authenticated"));
4780
+ return;
4781
+ }
4782
+ console.log(chalk.blue("This will log you out of Happy"));
4783
+ console.log(chalk.yellow("\u26A0\uFE0F You will need to re-authenticate to use Happy again"));
4784
+ const rl = node_readline.createInterface({
4785
+ input: process.stdin,
4786
+ output: process.stdout
4787
+ });
4788
+ const answer = await new Promise((resolve) => {
4789
+ rl.question(chalk.yellow("Are you sure you want to log out? (y/N): "), resolve);
4790
+ });
4791
+ rl.close();
4792
+ if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
4793
+ try {
4794
+ try {
4795
+ await stopDaemon();
4796
+ console.log(chalk.gray("Stopped daemon"));
4797
+ } catch {
4798
+ }
4799
+ if (node_fs.existsSync(happyDir)) {
4800
+ node_fs.rmSync(happyDir, { recursive: true, force: true });
4801
+ }
4802
+ console.log(chalk.green("\u2713 Successfully logged out"));
4803
+ console.log(chalk.gray(' Run "happy auth login" to authenticate again'));
4804
+ } catch (error) {
4805
+ throw new Error(`Failed to logout: ${error instanceof Error ? error.message : "Unknown error"}`);
4806
+ }
4807
+ } else {
4808
+ console.log(chalk.blue("Logout cancelled"));
4809
+ }
4810
+ }
4811
+ async function handleAuthShowBackup() {
4812
+ const credentials = await readCredentials();
4813
+ const settings = await readSettings();
4814
+ if (!credentials) {
4815
+ console.log(chalk.yellow("Not authenticated"));
4816
+ console.log(chalk.gray('Run "happy auth login" to authenticate first'));
4817
+ return;
4818
+ }
4819
+ const formattedBackupKey = formatSecretKeyForBackup(credentials.secret);
4820
+ console.log(chalk.bold("\n\u{1F4F1} Backup Key\n"));
4821
+ console.log(chalk.cyan("Your backup key:"));
4822
+ console.log(chalk.bold(formattedBackupKey));
4823
+ console.log("");
4824
+ console.log(chalk.cyan("Machine Information:"));
4825
+ console.log(` Machine ID: ${settings?.machineId || "not set"}`);
4826
+ console.log(` Host: ${os.hostname()}`);
4827
+ console.log("");
4828
+ console.log(chalk.bold("How to use this backup key:"));
4829
+ console.log(chalk.gray("\u2022 In Happy mobile app: Go to restore/link device and enter this key"));
4830
+ console.log(chalk.gray("\u2022 This key format matches what the mobile app expects"));
4831
+ console.log(chalk.gray("\u2022 You can type it with or without dashes - the app will normalize it"));
4832
+ console.log(chalk.gray("\u2022 Common typos (0\u2192O, 1\u2192I) are automatically corrected"));
4833
+ console.log("");
4834
+ console.log(chalk.yellow("\u26A0\uFE0F Keep this key secure - it provides full access to your account"));
4835
+ }
4836
+ async function handleAuthStatus() {
4837
+ const credentials = await readCredentials();
4838
+ const settings = await readSettings();
4839
+ console.log(chalk.bold("\nAuthentication Status\n"));
4840
+ if (!credentials) {
4841
+ console.log(chalk.red("\u2717 Not authenticated"));
4842
+ console.log(chalk.gray(' Run "happy auth login" to authenticate'));
4843
+ return;
4844
+ }
4845
+ console.log(chalk.green("\u2713 Authenticated"));
4846
+ const tokenPreview = credentials.token.substring(0, 30) + "...";
4847
+ console.log(chalk.gray(` Token: ${tokenPreview}`));
4848
+ if (settings?.machineId) {
4849
+ console.log(chalk.green("\u2713 Machine registered"));
4850
+ console.log(chalk.gray(` Machine ID: ${settings.machineId}`));
4851
+ console.log(chalk.gray(` Host: ${os.hostname()}`));
4852
+ } else {
4853
+ console.log(chalk.yellow("\u26A0\uFE0F Machine not registered"));
4854
+ console.log(chalk.gray(' Run "happy auth login --force" to fix this'));
4855
+ }
4856
+ console.log(chalk.gray(`
4857
+ Data directory: ${types$1.configuration.happyHomeDir}`));
4858
+ try {
4859
+ const { isDaemonRunning } = await Promise.resolve().then(function () { return utils; });
4860
+ const running = await isDaemonRunning();
4861
+ if (running) {
4862
+ console.log(chalk.green("\u2713 Daemon running"));
4863
+ } else {
4864
+ console.log(chalk.gray("\u2717 Daemon not running"));
4865
+ }
4866
+ } catch {
4867
+ console.log(chalk.gray("\u2717 Daemon not running"));
4868
+ }
4869
+ }
4870
+
4266
4871
  (async () => {
4267
4872
  const args = process.argv.slice(2);
4268
- let installationLocation = args.includes("--local") || process.env.HANDY_LOCAL ? "local" : "global";
4269
- types$1.initializeConfiguration(installationLocation);
4270
- types$1.initLoggerWithGlobalConfiguration();
4271
4873
  types$1.logger.debug("Starting happy CLI with args: ", process.argv);
4272
4874
  const subcommand = args[0];
4273
- if (subcommand === "logout") {
4875
+ if (subcommand === "doctor") {
4876
+ await runDoctorCommand();
4877
+ return;
4878
+ } else if (subcommand === "auth") {
4879
+ try {
4880
+ await handleAuthCommand(args.slice(1));
4881
+ } catch (error) {
4882
+ console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
4883
+ if (process.env.DEBUG) {
4884
+ console.error(error);
4885
+ }
4886
+ process.exit(1);
4887
+ }
4888
+ return;
4889
+ } else if (subcommand === "logout") {
4890
+ console.log(chalk.yellow('Note: "happy logout" is deprecated. Use "happy auth logout" instead.\n'));
4274
4891
  try {
4275
- await cleanKey();
4892
+ await handleAuthCommand(["logout"]);
4276
4893
  } catch (error) {
4277
4894
  console.error(chalk.red("Error:"), error instanceof Error ? error.message : "Unknown error");
4278
4895
  if (process.env.DEBUG) {
@@ -4294,12 +4911,92 @@ async function uninstall() {
4294
4911
  return;
4295
4912
  } else if (subcommand === "daemon") {
4296
4913
  const daemonSubcommand = args[1];
4297
- if (daemonSubcommand === "start") {
4914
+ if (daemonSubcommand === "list") {
4915
+ try {
4916
+ const sessions = await listDaemonSessions();
4917
+ if (sessions.length === 0) {
4918
+ console.log("No active sessions");
4919
+ } else {
4920
+ console.log("Active sessions:");
4921
+ const cleanSessions = sessions.map((s) => ({
4922
+ pid: s.pid,
4923
+ sessionId: s.happySessionId || `PID-${s.pid}`,
4924
+ startedBy: s.startedBy,
4925
+ directory: s.happySessionMetadataFromLocalWebhook?.directory || "unknown"
4926
+ }));
4927
+ console.log(JSON.stringify(cleanSessions, null, 2));
4928
+ }
4929
+ } catch (error) {
4930
+ console.log("No daemon running");
4931
+ }
4932
+ return;
4933
+ } else if (daemonSubcommand === "stop-session") {
4934
+ const sessionId = args[2];
4935
+ if (!sessionId) {
4936
+ console.error("Session ID required");
4937
+ process.exit(1);
4938
+ }
4939
+ try {
4940
+ const success = await stopDaemonSession(sessionId);
4941
+ console.log(success ? "Session stopped" : "Failed to stop session");
4942
+ } catch (error) {
4943
+ console.log("No daemon running");
4944
+ }
4945
+ return;
4946
+ } else if (daemonSubcommand === "start") {
4947
+ const happyBinPath = node_path.join(projectPath(), "bin", "happy.mjs");
4948
+ const child = child_process.spawn(happyBinPath, ["daemon", "start-sync"], {
4949
+ detached: true,
4950
+ stdio: "ignore",
4951
+ env: process.env
4952
+ });
4953
+ child.unref();
4954
+ let started = false;
4955
+ for (let i = 0; i < 50; i++) {
4956
+ if (await isDaemonRunning()) {
4957
+ started = true;
4958
+ break;
4959
+ }
4960
+ await new Promise((resolve) => setTimeout(resolve, 100));
4961
+ }
4962
+ if (started) {
4963
+ console.log("Daemon started successfully");
4964
+ } else {
4965
+ console.error("Failed to start daemon");
4966
+ process.exit(1);
4967
+ }
4968
+ process.exit(0);
4969
+ } else if (daemonSubcommand === "start-sync") {
4298
4970
  await startDaemon();
4299
4971
  process.exit(0);
4300
4972
  } else if (daemonSubcommand === "stop") {
4301
4973
  await stopDaemon();
4302
4974
  process.exit(0);
4975
+ } else if (daemonSubcommand === "status") {
4976
+ const state = await getDaemonState();
4977
+ if (!state) {
4978
+ console.log("Daemon is not running");
4979
+ } else {
4980
+ const isRunning = await isDaemonRunning();
4981
+ if (isRunning) {
4982
+ console.log("Daemon is running");
4983
+ console.log(` PID: ${state.pid}`);
4984
+ console.log(` Port: ${state.httpPort}`);
4985
+ console.log(` Started: ${new Date(state.startTime).toLocaleString()}`);
4986
+ console.log(` CLI Version: ${state.startedWithCliVersion}`);
4987
+ } else {
4988
+ console.log("Daemon state file exists but daemon is not running (stale)");
4989
+ }
4990
+ }
4991
+ process.exit(0);
4992
+ } else if (daemonSubcommand === "kill-runaway") {
4993
+ const { killRunawayHappyProcesses } = await Promise.resolve().then(function () { return utils; });
4994
+ const result = await killRunawayHappyProcesses();
4995
+ console.log(`Killed ${result.killed} runaway processes`);
4996
+ if (result.errors.length > 0) {
4997
+ console.log("Errors:", result.errors);
4998
+ }
4999
+ process.exit(0);
4303
5000
  } else if (daemonSubcommand === "install") {
4304
5001
  try {
4305
5002
  await install();
@@ -4319,13 +5016,16 @@ async function uninstall() {
4319
5016
  ${chalk.bold("happy daemon")} - Daemon management
4320
5017
 
4321
5018
  ${chalk.bold("Usage:")}
4322
- happy daemon start Start the daemon
4323
- happy daemon stop Stop the daemon
4324
- sudo happy daemon install Install the daemon (requires sudo)
4325
- sudo happy daemon uninstall Uninstall the daemon (requires sudo)
5019
+ happy daemon start Start the daemon (detached)
5020
+ happy daemon stop Stop the daemon (sessions stay alive)
5021
+ happy daemon stop --kill-managed Stop daemon and kill managed sessions
5022
+ happy daemon status Show daemon status
5023
+ happy daemon list List active sessions
5024
+ happy daemon stop-session <id> Stop a specific session
5025
+ happy daemon kill-runaway Kill all runaway Happy processes
4326
5026
 
4327
- ${chalk.bold("Note:")} The daemon runs in the background and provides persistent services.
4328
- Currently only supported on macOS.
5027
+ ${chalk.bold("Note:")} The daemon runs in the background and manages Claude sessions.
5028
+ Sessions spawned by the daemon will continue running after daemon stops unless --kill-managed is used.
4329
5029
  `);
4330
5030
  }
4331
5031
  return;
@@ -4334,104 +5034,107 @@ Currently only supported on macOS.
4334
5034
  let showHelp = false;
4335
5035
  let showVersion = false;
4336
5036
  let forceAuth = false;
5037
+ let forceAuthNew = false;
5038
+ const unknownArgs = [];
4337
5039
  for (let i = 0; i < args.length; i++) {
4338
5040
  const arg = args[i];
4339
5041
  if (arg === "-h" || arg === "--help") {
4340
5042
  showHelp = true;
5043
+ unknownArgs.push(arg);
4341
5044
  } else if (arg === "-v" || arg === "--version") {
4342
5045
  showVersion = true;
5046
+ unknownArgs.push(arg);
4343
5047
  } else if (arg === "--auth" || arg === "--login") {
4344
5048
  forceAuth = true;
4345
- } else if (arg === "-m" || arg === "--model") {
4346
- options.model = args[++i];
4347
- } else if (arg === "-p" || arg === "--permission-mode") {
4348
- options.permissionMode = z.z.enum(["default", "acceptEdits", "bypassPermissions", "plan"]).parse(args[++i]);
4349
- } else if (arg === "--local") ; else if (arg === "--happy-starting-mode") {
5049
+ } else if (arg === "--force-auth") {
5050
+ forceAuthNew = true;
5051
+ } else if (arg === "--happy-starting-mode") {
4350
5052
  options.startingMode = z.z.enum(["local", "remote"]).parse(args[++i]);
4351
- } else if (arg === "--claude-env") {
4352
- const envVar = args[++i];
4353
- const [key, value] = envVar.split("=", 2);
4354
- if (!key || value === void 0) {
4355
- console.error(chalk.red(`Invalid environment variable format: ${envVar}. Use KEY=VALUE`));
4356
- process.exit(1);
4357
- }
4358
- options.claudeEnvVars = { ...options.claudeEnvVars, [key]: value };
4359
- } else if (arg === "--claude-arg") {
4360
- const claudeArg = args[++i];
4361
- options.claudeArgs = [...options.claudeArgs || [], claudeArg];
4362
- } else if (arg === "--daemon-spawn") {
4363
- options.daemonSpawn = true;
5053
+ } else if (arg === "--yolo") {
5054
+ unknownArgs.push("--dangerously-skip-permissions");
5055
+ } else if (arg === "--started-by") {
5056
+ options.startedBy = args[++i];
4364
5057
  } else {
4365
- console.error(chalk.red(`Unknown argument: ${arg}`));
4366
- process.exit(1);
5058
+ unknownArgs.push(arg);
5059
+ if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
5060
+ unknownArgs.push(args[++i]);
5061
+ }
4367
5062
  }
4368
5063
  }
5064
+ if (unknownArgs.length > 0) {
5065
+ options.claudeArgs = [...options.claudeArgs || [], ...unknownArgs];
5066
+ }
4369
5067
  if (showHelp) {
4370
5068
  console.log(`
4371
5069
  ${chalk.bold("happy")} - Claude Code On the Go
4372
5070
 
4373
5071
  ${chalk.bold("Usage:")}
4374
- happy [options]
4375
- happy notify Send notification
4376
- happy logout Logs out of your account and removes data directory
4377
- happy daemon Manage the background daemon (macOS only)
5072
+ happy [options] Start Claude with mobile control
5073
+ happy auth Manage authentication
5074
+ happy notify Send push notification
5075
+ happy daemon Manage background service
4378
5076
 
4379
- ${chalk.bold("Options:")}
4380
- -h, --help Show this help message
4381
- -v, --version Show version
4382
- -m, --model <model> Claude model to use (default: sonnet)
4383
- -p, --permission-mode Permission mode: default, acceptEdits, bypassPermissions, or plan
4384
- --auth, --login Force re-authentication
4385
- --claude-env KEY=VALUE Set environment variable for Claude Code
4386
- --claude-arg ARG Pass additional argument to Claude CLI
4387
-
4388
- [Daemon Management]
4389
- --happy-daemon-start Start the daemon in background
4390
- --happy-daemon-stop Stop the daemon
4391
- --happy-daemon-install Install daemon to run on startup
4392
- --happy-daemon-uninstall Uninstall daemon from startup
5077
+ ${chalk.bold("Happy Options:")}
5078
+ --help Show this help message
5079
+ --yolo Skip all permissions (--dangerously-skip-permissions)
5080
+ --force-auth Force re-authentication
4393
5081
 
4394
- [Advanced]
4395
- --local < global | local >
4396
- Will use .happy folder in the current directory for storing your private key and debug logs.
4397
- You will require re-login each time you run this in a new directory.
4398
- --happy-starting-mode <interactive|remote>
4399
- Set the starting mode for new sessions (default: remote)
4400
- --happy-server-url <url>
4401
- Set the server URL (overrides HANDY_SERVER_URL environment variable)
5082
+ ${chalk.bold("\u{1F3AF} Happy supports ALL Claude options!")}
5083
+ Use any claude flag exactly as you normally would.
4402
5084
 
4403
5085
  ${chalk.bold("Examples:")}
4404
- happy Start a session with default settings
4405
- happy -m opus Use Claude Opus model
4406
- happy -p plan Use plan permission mode
4407
- happy --auth Force re-authentication before starting session
4408
- happy notify -p "Hello!" Send notification
4409
- happy --claude-env KEY=VALUE
4410
- Set environment variable for Claude Code
4411
- happy --claude-arg --option
4412
- Pass argument to Claude CLI
4413
- happy logout Logs out of your account and removes data directory
5086
+ happy Start session
5087
+ happy --yolo Start without permissions
5088
+ happy --verbose Enable verbose mode
5089
+ happy -c Continue last conversation
5090
+ happy auth login Authenticate
5091
+ happy notify -p "Done!" Send notification
5092
+
5093
+ ${chalk.bold("Happy is a wrapper around Claude Code that enables remote control via mobile app.")}
5094
+ ${chalk.bold('Use "happy daemon" for background service management.')}
4414
5095
 
4415
- [TODO: add after steve's refactor lands]
4416
- ${chalk.bold("Happy is a seamless passthrough to Claude CLI - so any commands that Claude CLI supports will work with Happy. Here is the help for Claude CLI:")}
4417
- TODO: exec cluade --help and show inline here
5096
+ ${chalk.gray("\u2500".repeat(60))}
5097
+ ${chalk.bold.cyan("Claude Code Options (from `claude --help`):")}
4418
5098
  `);
5099
+ const { execSync } = await import('child_process');
5100
+ try {
5101
+ const claudeHelp = execSync("claude --help", { encoding: "utf8" });
5102
+ console.log(claudeHelp);
5103
+ } catch (e) {
5104
+ console.log(chalk.yellow("Could not retrieve claude help. Make sure claude is installed."));
5105
+ }
4419
5106
  process.exit(0);
4420
5107
  }
4421
5108
  if (showVersion) {
4422
5109
  console.log(packageJson.version);
4423
5110
  process.exit(0);
4424
5111
  }
4425
- let credentials = await readCredentials();
4426
- if (!credentials || forceAuth) {
4427
- let res = await doAuth();
5112
+ let credentials;
5113
+ if (forceAuthNew) {
5114
+ console.log(chalk.yellow("Force authentication requested..."));
5115
+ try {
5116
+ await stopDaemon();
5117
+ } catch {
5118
+ }
5119
+ await clearCredentials();
5120
+ await clearMachineId();
5121
+ const result = await authAndSetupMachineIfNeeded();
5122
+ credentials = result.credentials;
5123
+ } else if (forceAuth) {
5124
+ console.log(chalk.yellow('Note: --auth is deprecated. Use "happy auth login" or --force-auth instead.\n'));
5125
+ const res = await doAuth();
4428
5126
  if (!res) {
4429
5127
  process.exit(1);
4430
5128
  }
4431
- credentials = res;
5129
+ await writeCredentials(res);
5130
+ const result = await authAndSetupMachineIfNeeded();
5131
+ credentials = result.credentials;
5132
+ } else {
5133
+ const result = await authAndSetupMachineIfNeeded();
5134
+ credentials = result.credentials;
4432
5135
  }
4433
- const settings = await readSettings() || { onboardingCompleted: false };
4434
- if (settings.daemonAutoStartWhenRunningHappy === void 0) {
5136
+ let settings = await readSettings();
5137
+ if (settings && settings.daemonAutoStartWhenRunningHappy === void 0) {
4435
5138
  console.log(chalk.cyan("\n\u{1F680} Happy Daemon Setup\n"));
4436
5139
  const rl = node_readline.createInterface({
4437
5140
  input: process.stdin,
@@ -4446,39 +5149,28 @@ TODO: exec cluade --help and show inline here
4446
5149
  });
4447
5150
  rl.close();
4448
5151
  const shouldAutoStart = answer.toLowerCase() !== "n";
4449
- settings.daemonAutoStartWhenRunningHappy = shouldAutoStart;
5152
+ settings = await updateSettings((settings2) => ({
5153
+ ...settings2,
5154
+ daemonAutoStartWhenRunningHappy: shouldAutoStart
5155
+ }));
4450
5156
  if (shouldAutoStart) {
4451
5157
  console.log(chalk.green("\u2713 Happy will start the background service automatically"));
4452
5158
  console.log(chalk.gray(" The service will run whenever you use the happy command"));
4453
5159
  } else {
4454
5160
  console.log(chalk.yellow(" You can enable this later by running: happy daemon install"));
4455
5161
  }
4456
- await writeSettings(settings);
4457
5162
  }
4458
- if (settings.daemonAutoStartWhenRunningHappy) {
5163
+ if (settings && settings.daemonAutoStartWhenRunningHappy) {
4459
5164
  types$1.logger.debug("Starting Happy background service...");
4460
5165
  if (!await isDaemonRunning()) {
4461
- const happyPath = process.argv[1];
4462
- const runningFromBuiltBinary = happyPath.endsWith("happy") || happyPath.endsWith("happy.cmd");
4463
- const daemonArgs = ["daemon", "start"];
4464
- if (installationLocation === "local") {
4465
- daemonArgs.push("--local");
4466
- }
4467
- let executable, args2;
4468
- if (runningFromBuiltBinary) {
4469
- executable = happyPath;
4470
- args2 = daemonArgs;
4471
- } else {
4472
- executable = "npx";
4473
- args2 = ["tsx", happyPath, ...daemonArgs];
4474
- }
4475
- const daemonProcess = child_process.spawn(executable, args2, {
5166
+ const happyBinPath = node_path.join(projectPath(), "bin", "happy.mjs");
5167
+ const daemonProcess = child_process.spawn(happyBinPath, ["daemon", "start-sync"], {
4476
5168
  detached: true,
4477
- stdio: ["ignore", "inherit", "inherit"]
4478
- // Show stdout/stderr for debugging
5169
+ stdio: "ignore",
5170
+ env: process.env
4479
5171
  });
4480
5172
  daemonProcess.unref();
4481
- await new Promise((resolve) => setTimeout(resolve, 200));
5173
+ await new Promise((resolve) => setTimeout(resolve, 500));
4482
5174
  }
4483
5175
  }
4484
5176
  try {
@@ -4492,34 +5184,6 @@ TODO: exec cluade --help and show inline here
4492
5184
  }
4493
5185
  }
4494
5186
  })();
4495
- async function cleanKey() {
4496
- const happyDir = types$1.configuration.happyDir;
4497
- if (!node_fs.existsSync(happyDir)) {
4498
- console.log(chalk.yellow("No happy data directory found at:"), happyDir);
4499
- return;
4500
- }
4501
- console.log(chalk.blue("Found happy data directory at:"), happyDir);
4502
- console.log(chalk.yellow("\u26A0\uFE0F This will remove all authentication data and require reconnecting your phone."));
4503
- const rl = node_readline.createInterface({
4504
- input: process.stdin,
4505
- output: process.stdout
4506
- });
4507
- const answer = await new Promise((resolve) => {
4508
- rl.question(chalk.yellow("Are you sure you want to remove the happy data directory? (y/N): "), resolve);
4509
- });
4510
- rl.close();
4511
- if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
4512
- try {
4513
- node_fs.rmSync(happyDir, { recursive: true, force: true });
4514
- console.log(chalk.green("\u2713 Happy data directory removed successfully"));
4515
- console.log(chalk.blue("\u2139\uFE0F You will need to reconnect your phone on the next session"));
4516
- } catch (error) {
4517
- throw new Error(`Failed to remove data directory: ${error instanceof Error ? error.message : "Unknown error"}`);
4518
- }
4519
- } else {
4520
- console.log(chalk.blue("Operation cancelled"));
4521
- }
4522
- }
4523
5187
  async function handleNotifyCommand(args) {
4524
5188
  let message = "";
4525
5189
  let title = "";